From e18753b54aabea56926c2435ea10c823606d7114 Mon Sep 17 00:00:00 2001 From: Yarmo Mackenbach Date: Sun, 2 May 2021 12:49:52 +0200 Subject: [PATCH] Improve layout and rendering of profiles --- index.js | 46 +- routes/profile.js | 47 +- routes/static.js | 24 +- server/index.js | 122 ++++ server/keys.js | 122 ++++ server/utils.js | 36 ++ static/kx-claim.js | 205 +++++++ static/kx-key.js | 72 +++ static/kx-styles.css | 206 +++++++ static/scripts.js | 1155 +++++++------------------------------- static/styles.css | 93 +-- views/profile-failed.pug | 9 + views/profile.pug | 45 +- 13 files changed, 1070 insertions(+), 1112 deletions(-) create mode 100644 server/index.js create mode 100644 server/keys.js create mode 100644 server/utils.js create mode 100644 static/kx-claim.js create mode 100644 static/kx-key.js create mode 100644 static/kx-styles.css create mode 100644 views/profile-failed.pug diff --git a/index.js b/index.js index bb8bbfe..c41fce7 100644 --- a/index.js +++ b/index.js @@ -27,37 +27,35 @@ 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 . */ -const express = require('express'); -const fs = require('fs'); -const app = express(); -const env = {}; -const { stringReplace } = require('string-replace-middleware'); -require('dotenv').config(); +const express = require('express') +const fs = require('fs') +const app = express() +const { stringReplace } = require('string-replace-middleware') +require('dotenv').config() -let packageData = JSON.parse(fs.readFileSync('package.json')); +const packageData = JSON.parse(fs.readFileSync('package.json')) -app.set('env', process.env.NODE_ENV || "production"); -app.set('view engine', 'pug'); -app.set('port', process.env.PORT || 3000); -app.set('domain', process.env.DOMAIN || "keyoxide.org"); -app.set('keyoxide_version', packageData.version); -app.set('onion_url', process.env.ONION_URL); +app.set('env', process.env.NODE_ENV || "production") +app.set('view engine', 'pug') +app.set('port', process.env.PORT || 3000) +app.set('domain', process.env.DOMAIN || "keyoxide.org") +app.set('keyoxide_version', packageData.version) +app.set('onion_url', process.env.ONION_URL) -app.use('/favicon.svg', express.static('favicon.svg')); -app.use('/robots.txt', express.static('robots.txt')); +app.use('/favicon.svg', express.static('favicon.svg')) +app.use('/robots.txt', express.static('robots.txt')) app.use(stringReplace({ - PLACEHOLDER__XMPP_VCARD_SERVER_DOMAIN: process.env.XMPP_VCARD_SERVER_DOMAIN || 'xmpp-vcard.keyoxide.org' + PLACEHOLDER__PROXY_HOSTNAME: process.env.PROXY_HOSTNAME || 'null' }, { contentTypeFilterRegexp: /application\/javascript/, -})); +})) -app.use('/', require('./routes/main')); -app.use('/static', require('./routes/static')); -app.use('/server', require('./routes/server')); -app.use('/util', require('./routes/util')); -app.use('/', require('./routes/profile')); +app.use('/', require('./routes/main')) +app.use('/static', require('./routes/static')) +app.use('/util', require('./routes/util')) +app.use('/', require('./routes/profile')) app.listen(app.get('port'), () => { - console.log(`Node server listening at http://localhost:${app.get('port')}`); -}); + console.log(`Node server listening at http://localhost:${app.get('port')}`) +}) diff --git a/routes/profile.js b/routes/profile.js index ff63ceb..95ead2c 100644 --- a/routes/profile.js +++ b/routes/profile.js @@ -27,30 +27,43 @@ 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 . */ -const router = require('express').Router(); +const router = require('express').Router() +const kx = require('../server') -router.get('/sig', function(req, res) { +router.get('/sig', (req, res) => { res.render('profile', { mode: 'sig' }) -}); +}) -router.get('/wkd/:input', function(req, res) { - res.render('profile', { mode: 'wkd', uid: req.params.input }) -}); +router.get('/wkd/:id', async (req, res) => { + const data = await kx.generateWKDProfile(req.params.id) + if (data.errors.length > 0) { + return res.render('profile-failed', { data: data }) + } + res.render('profile', { data: data }) +}) -router.get('/hkp/:input', function(req, res) { - res.render('profile', { mode: 'hkp', uid: req.params.input }) -}); +router.get('/hkp/:id', async (req, res) => { + const data = await kx.generateHKPProfile(req.params.id) + if (data.errors.length > 0) { + return res.render('profile-failed', { data: data }) + } + res.render('profile', { data: data }) +}) -router.get('/hkp/:server/:input', function(req, res) { - res.render('profile', { mode: 'hkp', uid: req.params.input, server: req.params.server }) -}); +router.get('/hkp/:server/:id', async (req, res) => { + const data = await kx.generateHKPProfile(req.params.id, req.params.server) + if (data.errors.length > 0) { + return res.render('profile-failed', { data: data }) + } + res.render('profile', { data: data }) +}) -router.get('/keybase/:username/:fingerprint', function(req, res) { +router.get('/keybase/:username/:fingerprint', async (req, res) => { res.render('profile', { mode: 'keybase', uid: `${req.params.username}/${req.params.fingerprint}` }) -}); +}) -router.get('/:input', function(req, res) { +router.get('/:input', async (req, res) => { res.render('profile', { mode: 'auto', uid: req.params.input }) -}); +}) -module.exports = router; +module.exports = router diff --git a/routes/static.js b/routes/static.js index 87326d2..23eb1df 100644 --- a/routes/static.js +++ b/routes/static.js @@ -27,37 +27,37 @@ 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 . */ -const express = require('express'); -const router = require('express').Router(); +const express = require('express') +const router = require('express').Router() router.get('/doip.min.js', function(req, res) { res.sendFile(`node_modules/doipjs/dist/doip.min.js`, { root: `${__dirname}/../` }) -}); +}) router.get('/doip.js', function(req, res) { res.sendFile(`node_modules/doipjs/dist/doip.js`, { root: `${__dirname}/../` }) -}); +}) router.get('/openpgp.min.js', function(req, res) { res.sendFile(`node_modules/openpgp/dist/openpgp.min.js`, { root: `${__dirname}/../` }) -}); +}) router.get('/openpgp.min.js.map', function(req, res) { res.sendFile(`node_modules/openpgp/dist/openpgp.min.js.map`, { root: `${__dirname}/../` }) -}); +}) router.get('/qrcode.min.js', function(req, res) { res.sendFile(`node_modules/qrcode/build/qrcode.min.js`, { root: `${__dirname}/../` }) -}); +}) router.get('/qrcode.min.js.map', function(req, res) { res.sendFile(`node_modules/qrcode/build/qrcode.min.js.map`, { root: `${__dirname}/../` }) -}); +}) router.get('/dialog-polyfill.js', function(req, res) { res.sendFile(`node_modules/dialog-polyfill/dist/dialog-polyfill.js`, { root: `${__dirname}/../` }) -}); +}) router.get('/dialog-polyfill.css', function(req, res) { res.sendFile(`node_modules/dialog-polyfill/dist/dialog-polyfill.css`, { root: `${__dirname}/../` }) -}); +}) -router.use('/', express.static('static')); +router.use('/', express.static('static')) -module.exports = router; +module.exports = router diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..d4e2ad1 --- /dev/null +++ b/server/index.js @@ -0,0 +1,122 @@ +/* +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 . + +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 . +*/ +const doip = require('doipjs') +const openpgp = require('openpgp') +const keys = require('./keys') + +const generateWKDProfile = async (id) => { + return keys.fetchWKD(id) + .then(async key => { + let keyData = await doip.keys.process(key.publicKey) + keyData.key.fetchMethod = 'wkd' + keyData.key.uri = key.fetchURL + keyData = processKeyData(keyData) + + return { + key: key, + keyData: keyData, + extra: await computeExtraData(key, keyData), + errors: [] + } + }) + .catch(err => { + return { + key: null, + keyData: null, + extra: null, + errors: [err.message] + } + }) +} + +const generateHKPProfile = async (id, keyserverDomain) => { + return keys.fetchHKP(id, keyserverDomain) + .then(async key => { + let keyData = await doip.keys.process(key.publicKey) + keyData.key.fetchMethod = 'hkp' + keyData.key.uri = key.fetchURL + keyData = processKeyData(keyData) + + return { + key: key, + keyData: keyData, + extra: await computeExtraData(key, keyData), + errors: [] + } + }) + .catch(err => { + return { + key: null, + keyData: null, + extra: null, + errors: [err.message] + } + }) +} + +const processKeyData = (keyData) => { + keyData.users.forEach(user => { + // Match claims + user.claims.forEach(claim => { + claim.match() + }) + + // Sort claims + user.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) { + return -1 + } + if (a.matches[0].serviceprovider.name > b.matches[0].serviceprovider.name) { + return 1 + } + return 0 + }) + }) + + return keyData +} + +const computeExtraData = async (key, keyData) => { + // Get the primary user + const primaryUser = await key.publicKey.getPrimaryUser() + + // Compute hash needed for avatar services + const profileHash = openpgp.util.str_to_hex(openpgp.util.Uint8Array_to_str(await openpgp.crypto.hash.md5(openpgp.util.str_to_Uint8Array(primaryUser.user.userId.email)))) + + return { + avatarURL: `https://www.gravatar.com/avatar/${profileHash}?s=128&d=mm` + } +} + +exports.generateWKDProfile = generateWKDProfile +exports.generateHKPProfile = generateHKPProfile diff --git a/server/keys.js b/server/keys.js new file mode 100644 index 0000000..d5dac21 --- /dev/null +++ b/server/keys.js @@ -0,0 +1,122 @@ +/* +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 . + +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 . +*/ +const got = require('got') +const doip = require('doipjs') +const openpgp = require('openpgp') +const utils = require('./utils') + +const fetchWKD = (id) => { + return new Promise(async (resolve, reject) => { + let output = { + publicKey: null, + fetchURL: null + } + + const [, localPart, domain] = /([^\@]*)@(.*)/.exec(id) + const localEncoded = await utils.computeWKDLocalPart(localPart) + const urlAdvanced = `https://openpgpkey.${domain}/.well-known/openpgpkey/${domain}/hu/${localEncoded}` + const urlDirect = `https://${domain}/.well-known/openpgpkey/hu/${localEncoded}` + let plaintext + + try { + plaintext = await got(urlAdvanced).then((response) => { + if (response.statusCode === 200) { + output.fetchURL = urlAdvanced + return new Uint8Array(response.rawBody) + } else { + return null + } + }) + } catch (e) { + try { + plaintext = await got(urlDirect).then((response) => { + if (response.statusCode === 200) { + output.fetchURL = urlDirect + return new Uint8Array(response.rawBody) + } else { + return null + } + }) + } catch (error) { + reject(new Error("No public keys could be fetched using WKD")) + } + } + + if (!plaintext) { + reject(new Error("No public keys could be fetched using WKD")) + } + + try { + output.publicKey = (await openpgp.key.read(plaintext)).keys[0] + } catch(error) { + reject(new Error("No public keys could be read from the data fetched using WKD")) + } + + if (!output.publicKey) { + reject(new Error("No public keys could be read from the data fetched using WKD")) + } + + resolve(output) + }) +} + +const fetchHKP = (id, keyserverDomain) => { + return new Promise(async (resolve, reject) => { + let output = { + publicKey: null, + fetchURL: null + } + + keyserverDomain = keyserverDomain ? keyserverDomain : 'keys.openpgp.org' + + let query = '' + if (id.includes('@')) { + query = id + } else { + query = `0x${id}` + } + + try { + output.publicKey = await doip.keys.fetchHKP(id, keyserverDomain) + output.fetchURL = `https://${keyserverDomain}/pks/lookup?op=get&options=mr&search=${query}` + } catch(error) { + reject(new Error("No public keys could be fetched using HKP")) + } + + if (!output.publicKey) { + reject(new Error("No public keys could be fetched using HKP")) + } + + resolve(output) + }) +} + +exports.fetchWKD = fetchWKD +exports.fetchHKP = fetchHKP diff --git a/server/utils.js b/server/utils.js new file mode 100644 index 0000000..f87b8c5 --- /dev/null +++ b/server/utils.js @@ -0,0 +1,36 @@ +/* +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 . + +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 . +*/ +const openpgp = require('openpgp') + +exports.computeWKDLocalPart = async (message) => { + const data = openpgp.util.str_to_Uint8Array(message.toLowerCase()); + const hash = await openpgp.crypto.hash.sha1(data); + return openpgp.util.encodeZBase32(hash); +} diff --git a/static/kx-claim.js b/static/kx-claim.js new file mode 100644 index 0000000..ce52b81 --- /dev/null +++ b/static/kx-claim.js @@ -0,0 +1,205 @@ +class Claim extends HTMLElement { + // Specify the attributes to observe + static get observedAttributes() { + return ['data-claim']; + } + + constructor() { + // Call super + super(); + + // Shadow root + this.attachShadow({mode: 'open'}); + + // Details element + const details = document.createElement('details'); + details.setAttribute('class', 'kx-item'); + + // Summary element + const summary = details.appendChild(document.createElement('summary')); + + // Info + const info = summary.appendChild(document.createElement('div')); + info.setAttribute('class', 'info'); + + // Info > Service provider + const serviceProvider = info.appendChild(document.createElement('p')); + serviceProvider.setAttribute('class', 'subtitle'); + + // Info > Profile + const profile = info.appendChild(document.createElement('p')); + profile.setAttribute('class', 'title'); + + // Icons + const icons = summary.appendChild(document.createElement('div')); + icons.setAttribute('class', 'icons'); + + const icons__verificationStatus = icons.appendChild(document.createElement('div')); + icons__verificationStatus.setAttribute('class', 'verificationStatus'); + + const icons__verificationStatus__inProgress = icons__verificationStatus.appendChild(document.createElement('div')); + icons__verificationStatus__inProgress.setAttribute('class', 'inProgress'); + + // Details content + const content = details.appendChild(document.createElement('div')); + content.setAttribute('class', 'content'); + + // Load CSS stylesheet + const linkCSS = document.createElement('link'); + linkCSS.setAttribute('rel', 'stylesheet'); + linkCSS.setAttribute('href', '/static/kx-styles.css'); + + // Attach the elements to the shadow DOM + this.shadowRoot.append(linkCSS, details); + } + + attributeChangedCallback(name, oldValue, newValue) { + this.updateContent(newValue); + } + + async verify() { + const claim = new doip.Claim(JSON.parse(this.getAttribute('data-claim'))); + await claim.verify({ + proxy: { + policy: 'adaptive', + hostname: 'PLACEHOLDER__PROXY_HOSTNAME' + } + }); + this.setAttribute('data-claim', JSON.stringify(claim)); + } + + updateContent(value) { + const shadow = this.shadowRoot; + const claim = new doip.Claim(JSON.parse(value)); + + switch (claim.matches[0].serviceprovider.name) { + case 'dns': + case 'xmpp': + case 'irc': + shadow.querySelector('.info .subtitle').innerText = claim.matches[0].serviceprovider.name.toUpperCase(); + break; + + default: + shadow.querySelector('.info .subtitle').innerText = claim.matches[0].serviceprovider.name; + break; + } + shadow.querySelector('.info .title').innerText = claim.matches[0].profile.display; + + try { + if (claim.status === 'verified') { + shadow.querySelector('.icons .verificationStatus').setAttribute('data-value', claim.verification.result ? 'success' : 'failed'); + } else { + shadow.querySelector('.icons .verificationStatus').setAttribute('data-value', 'running'); + } + } catch (error) { + shadow.querySelector('.icons .verificationStatus').setAttribute('data-value', 'failed'); + } + + const elContent = shadow.querySelector('.content'); + elContent.innerHTML = ``; + + // Handle failed ambiguous claim + if (claim.status === 'verified' && !claim.verification.result && claim.isAmbiguous()) { + shadow.querySelector('.info .subtitle').innerText = '---'; + + const subsection0 = elContent.appendChild(document.createElement('div')); + subsection0.setAttribute('class', 'subsection'); + const subsection0_icon = subsection0.appendChild(document.createElement('img')); + subsection0_icon.setAttribute('src', '/static/img/alert-decagram.png'); + const subsection0_text = subsection0.appendChild(document.createElement('div')); + + const message = subsection0_text.appendChild(document.createElement('p')); + message.innerHTML = `None of the matched service providers could be verified. Keyoxide is not able to determine which is the correct service provider and why the verification process failed.`; + return; + } + + // Links to profile and proof + const subsection1 = elContent.appendChild(document.createElement('div')); + subsection1.setAttribute('class', 'subsection'); + const subsection1_icon = subsection1.appendChild(document.createElement('img')); + subsection1_icon.setAttribute('src', '/static/img/link.png'); + const subsection1_text = subsection1.appendChild(document.createElement('div')); + + const profile_link = subsection1_text.appendChild(document.createElement('p')); + if (claim.matches[0].profile.uri) { + profile_link.innerHTML = `Profile link: ${claim.matches[0].profile.uri}`; + } else { + profile_link.innerHTML = `Profile link: not accessible from browser`; + } + + const proof_link = subsection1_text.appendChild(document.createElement('p')); + if (claim.matches[0].proof.uri) { + proof_link.innerHTML = `Proof link: ${claim.matches[0].proof.uri}`; + } else { + proof_link.innerHTML = `Proof link: not accessible from browser`; + } + + elContent.appendChild(document.createElement('hr')); + + // Claim verification status + const subsection2 = elContent.appendChild(document.createElement('div')); + subsection2.setAttribute('class', 'subsection'); + const subsection2_icon = subsection2.appendChild(document.createElement('img')); + subsection2_icon.setAttribute('src', '/static/img/decagram.png'); + const subsection2_text = subsection2.appendChild(document.createElement('div')); + + const verification = subsection2_text.appendChild(document.createElement('p')); + if (claim.status === 'verified') { + verification.innerHTML = `Claim verification has completed.`; + subsection2_icon.setAttribute('src', '/static/img/check-decagram.png'); + } else { + verification.innerHTML = `Claim verification is in progress…`; + return; + } + + elContent.appendChild(document.createElement('hr')); + + // Result of claim verification + const subsection3 = elContent.appendChild(document.createElement('div')); + subsection3.setAttribute('class', 'subsection'); + const subsection3_icon = subsection3.appendChild(document.createElement('img')); + subsection3_icon.setAttribute('src', '/static/img/shield-search.png'); + const subsection3_text = subsection3.appendChild(document.createElement('div')); + + const result = subsection3_text.appendChild(document.createElement('p')); + result.innerHTML = `The claim ${claim.verification.result ? 'HAS BEEN' : 'COULD NOT BE'} verified by the proof.`; + + // Additional info + if (claim.verification.proof.viaProxy) { + elContent.appendChild(document.createElement('hr')); + + const subsection4 = elContent.appendChild(document.createElement('div')); + subsection4.setAttribute('class', 'subsection'); + const subsection4_icon = subsection4.appendChild(document.createElement('img')); + subsection4_icon.setAttribute('src', '/static/img/information.png'); + const subsection4_text = subsection4.appendChild(document.createElement('div')); + + const result_proxyUsed = subsection4_text.appendChild(document.createElement('p')); + result_proxyUsed.innerHTML = `A proxy was used to fetch the proof: PLACEHOLDER__PROXY_HOSTNAME`; + } + + // TODO Display errors + // if (claim.verification.errors.length > 0) { + // console.log(claim.verification); + // elContent.appendChild(document.createElement('hr')); + + // const subsection5 = elContent.appendChild(document.createElement('div')); + // subsection5.setAttribute('class', 'subsection'); + // const subsection5_icon = subsection5.appendChild(document.createElement('img')); + // subsection5_icon.setAttribute('src', '/static/img/alert-circle.png'); + // const subsection5_text = subsection5.appendChild(document.createElement('div')); + + // claim.verification.errors.forEach(message => { + // const error = subsection5_text.appendChild(document.createElement('p')); + + // if (message instanceof Error) { + // error.innerText = message.message; + // } else { + // error.innerText = message; + // } + // }); + // } + } +} + +customElements.define('kx-claim', Claim); diff --git a/static/kx-key.js b/static/kx-key.js new file mode 100644 index 0000000..f9e671b --- /dev/null +++ b/static/kx-key.js @@ -0,0 +1,72 @@ +class Key extends HTMLElement { + // Specify the attributes to observe + static get observedAttributes() { + return ['data-keydata']; + } + + constructor() { + // Call super + super(); + + // Shadow root + this.attachShadow({mode: 'open'}); + + // Details element + const details = document.createElement('details'); + details.setAttribute('class', 'kx-item'); + + // Summary element + const summary = details.appendChild(document.createElement('summary')); + + // Info + const info = summary.appendChild(document.createElement('div')); + info.setAttribute('class', 'info'); + + // Info > Protocol + const serviceProvider = info.appendChild(document.createElement('p')); + serviceProvider.setAttribute('class', 'subtitle'); + + // Info > Fingerprint + const profile = info.appendChild(document.createElement('p')); + profile.setAttribute('class', 'title'); + + // Details content + const content = details.appendChild(document.createElement('div')); + content.setAttribute('class', 'content'); + + // Load CSS stylesheet + const linkCSS = document.createElement('link'); + linkCSS.setAttribute('rel', 'stylesheet'); + linkCSS.setAttribute('href', '/static/kx-styles.css'); + + // Attach the elements to the shadow DOM + this.shadowRoot.append(linkCSS, details); + } + + attributeChangedCallback(name, oldValue, newValue) { + this.updateContent(newValue); + } + + updateContent(value) { + const shadow = this.shadowRoot; + const data = JSON.parse(value); + + shadow.querySelector('.info .subtitle').innerText = data.key.fetchMethod; + shadow.querySelector('.info .title').innerText = data.fingerprint; + + const elContent = shadow.querySelector('.content'); + elContent.innerHTML = ``; + + // Link to key + const subsection1 = elContent.appendChild(document.createElement('div')); + subsection1.setAttribute('class', 'subsection'); + const subsection1_icon = subsection1.appendChild(document.createElement('img')); + subsection1_icon.setAttribute('src', '/static/img/link.png'); + const subsection1_text = subsection1.appendChild(document.createElement('div')); + + const profile_link = subsection1_text.appendChild(document.createElement('p')); + profile_link.innerHTML = `Key link: ${data.key.uri}`; + } +} + +customElements.define('kx-key', Key); diff --git a/static/kx-styles.css b/static/kx-styles.css new file mode 100644 index 0000000..45efa65 --- /dev/null +++ b/static/kx-styles.css @@ -0,0 +1,206 @@ +* { + box-sizing: border-box; +} +details.kx-item { + width: 100%; + border-radius: 8px; +} +details.kx-item p { + margin: 0; + word-break: break-word; +} +details.kx-item a { + color: var(--blue-700); +} +details.kx-item hr { + border: none; + border-top: 2px solid var(--purple-100); +} +details.kx-item .content { + padding: 12px; + border: solid 3px var(--purple-100); + border-top: 0px; + border-radius: 0px 0px 8px 8px; +} +details.kx-item summary { + display: flex; + align-items: center; + padding: 8px 12px; + background-color: var(--purple-100); + border: solid 3px var(--purple-100); + border-radius: 8px; + list-style: none; + cursor: pointer; +} +details.kx-item summary::-webkit-details-marker { + display: none; +} +details.kx-item summary:hover, summary:focus { + border-color: var(--purple-400); +} +details[open] summary { + border-radius: 8px 8px 0px 0px; +} +details.kx-item summary .info { + flex: 1; +} +details.kx-item summary .info .title { + font-size: 1.1em; +} +details.kx-item summary .claim__description p { + font-size: 1.4rem; + line-height: 2rem; +} +details.kx-item summary .claim__links p, p.subtle-links { + display: flex; + align-items: center; + flex-wrap: wrap; + font-size: 1rem; + color: var(--grey-700); +} +details.kx-item summary .claim__links a, summary .claim__links span, p.subtle-links a { + font-size: 1rem; + margin: 0 10px 0 0; + color: var(--grey-700); +} +details.kx-item summary .subtitle { + color: var(--purple-700); +} +details.kx-item summary .verificationStatus { + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-width: 48px; + height: 48px; + border-radius: 100%; + color: #fff; + font-size: 2rem; + user-select: none; +} +details.kx-item summary .verificationStatus::after { + position: absolute; + display: flex; + top: 0; + left: 0; + right: 0; + bottom: 0; + align-items: center; + justify-content: center; +} +details.kx-item summary .verificationStatus .inProgress { + opacity: 0; + transition: all 0.4s ease; + pointer-events: none; +} +details.kx-item summary .verificationStatus[data-value="success"] { + content: "v"; + background-color: var(--green-600); +} +details.kx-item summary .verificationStatus[data-value="success"]::after { + content: "✔"; +} +details.kx-item summary .verificationStatus[data-value="failed"] { + background-color: var(--red-400); +} +details.kx-item summary .verificationStatus[data-value="failed"]::after { + content: "✕"; +} +details.kx-item summary .verificationStatus[data-value="running"] .inProgress { + opacity: 1; +} + +details.kx-item .subsection { + display: flex; + align-items: center; + gap: 16px; +} +details.kx-item .subsection > img { + width: 24px; + height: 24px; + opacity: 0.4; +} + +@media screen and (max-width: 640px) { + details.kx-item summary .claim__description p { + font-size: 1.2rem; + } + details.kx-item summary .claim__links a, p.subtle-links a { + font-size: 0.9rem; + } +} +@media screen and (max-width: 480px) { + summary .claim__description p { + font-size: 1rem; + } + details.kx-item summary .verificationStatus { + min-width: 36px; + height: 36px; + font-size: 1.6rem; + } +} + +details.kx-item .inProgress { + font-size: 10px; + margin: 50px auto; + text-indent: -9999em; + width: 4.8em; + height: 4.8em; + border-radius: 50%; + background: var(--purple-400); + background: -moz-linear-gradient(left, var(--purple-400) 10%, rgba(255, 255, 255, 0) 42%); + background: -webkit-linear-gradient(left, var(--purple-400) 10%, rgba(255, 255, 255, 0) 42%); + background: -o-linear-gradient(left, var(--purple-400) 10%, rgba(255, 255, 255, 0) 42%); + background: -ms-linear-gradient(left, var(--purple-400) 10%, rgba(255, 255, 255, 0) 42%); + background: linear-gradient(to right, var(--purple-400) 10%, rgba(255, 255, 255, 0) 42%); + position: relative; + -webkit-animation: load3 1.4s infinite linear; + animation: load3 1.4s infinite linear; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); +} +details.kx-item .inProgress:before { + width: 50%; + height: 50%; + background: var(--purple-400); + border-radius: 100% 0 0 0; + position: absolute; + top: 0; + left: 0; + content: ''; +} +details.kx-item .inProgress:after { + background: var(--purple-100); + width: 65%; + height: 65%; + border-radius: 50%; + content: ''; + margin: auto; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; +} + +@-webkit-keyframes load3 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes load3 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} diff --git a/static/scripts.js b/static/scripts.js index aab38a9..49f4ba1 100644 --- a/static/scripts.js +++ b/static/scripts.js @@ -27,997 +27,234 @@ 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 . */ -async function verifySignature(opts) { - // Init - const elRes = document.body.querySelector("#result"); - const elResContent = document.body.querySelector("#resultContent"); - let keyData, feedback, signature, verified, valid; +const claims = document.querySelectorAll('kx-claim'); - // Reset feedback - elRes.innerHTML = ""; - elRes.classList.remove('green'); - elRes.classList.remove('red'); - elResContent.innerHTML = ""; +claims.forEach(function(claim) { + claim.verify(); +}); - try { - // Get key data - keyData = await fetchKeys(opts); - - // Handle missing signature - if (opts.signature == null) { throw("No signature was provided."); } - - // Try two different methods of signature reading - let readError = null; - try { - signature = await openpgp.message.readArmored(opts.signature); - } catch(e) { - readError = e; - } - try { - signature = await openpgp.cleartext.readArmored(opts.signature); - } catch(e) { - readError = e; - } - if (signature == null) { throw(readError) }; - - // Verify the signature - verified = await openpgp.verify({ - message: signature, - publicKeys: keyData.publicKey - }); - valid = verified.signatures[0].valid; - } catch (e) { - console.error(e); - elRes.innerHTML = e; - elRes.classList.remove('green'); - elRes.classList.add('red'); - return; - } - - // Init feedback to empty string - feedback = ''; - - // If content was extracted from signature - if (keyData.sigContent) { - elResContent.innerHTML = "Signature content:
"+sigContent+""; - } - - // Provide different feedback depending on key input mode - if (opts.mode == "signature" && keyData.sigUserId) { - if (valid) { - feedback += "The message was signed by the userId extracted from the signature.
"; - feedback += 'UserId: '+keyData.sigUserId+'
'; - feedback += "Fingerprint: "+keyData.fingerprint+"
"; - elRes.classList.remove('red'); - elRes.classList.add('green'); - } else { - feedback += "The message's signature COULD NOT BE verified using the userId extracted from the signature.
"; - feedback += 'UserId: '+keyData.sigUserId+'
'; - elRes.classList.remove('green'); - elRes.classList.add('red'); - } - } else if (opts.mode == "signature" && keyData.sigKeyId) { - if (valid) { - feedback += "The message was signed by the keyId extracted from the signature.
"; - feedback += 'KeyID: '+keyData.sigKeyId+'
'; - feedback += "Fingerprint: "+keyData.fingerprint+"

"; - feedback += "!!! You should manually verify the fingerprint to confirm the signer's identity !!!"; - elRes.classList.remove('red'); - elRes.classList.add('green'); - } else { - feedback += "The message's signature COULD NOT BE verified using the keyId extracted from the signature.
"; - feedback += 'KeyID: '+keyData.sigKeyId+'
'; - elRes.classList.remove('green'); - elRes.classList.add('red'); - } - } else { - if (valid) { - feedback += "The message was signed by the provided key ("+opts.mode+").
"; - feedback += "Fingerprint: "+keyData.fingerprint+"
"; - elRes.classList.remove('red'); - elRes.classList.add('green'); - } else { - feedback += "The message's signature COULD NOT BE verified using the provided key ("+opts.mode+").
"; - elRes.classList.remove('green'); - elRes.classList.add('red'); - } - } - - // Display feedback - elRes.innerHTML = feedback; -}; - -async function encryptMessage(opts) { - // Init - const elEnc = document.body.querySelector("#message"); - const elRes = document.body.querySelector("#result"); - const elBtn = document.body.querySelector("[name='submit']"); - let keyData, feedback, message, encrypted; - - // Reset feedback - elRes.innerHTML = ""; - elRes.classList.remove('green'); - elRes.classList.remove('red'); - - try { - // Get key data - keyData = await fetchKeys(opts); - - // Handle missing message - if (opts.message == null) { - throw("No message was provided."); - } - - // Encrypt the message - encrypted = await openpgp.encrypt({ - message: openpgp.message.fromText(opts.message), - publicKeys: keyData.publicKey - }); - } catch (e) { - console.error(e); - elRes.innerHTML = e; - elRes.classList.remove('green'); - elRes.classList.add('red'); - return; - } - - // Display encrypted data - elEnc.value = encrypted.data; - elEnc.toggleAttribute("readonly"); - elBtn.setAttribute("disabled", "true"); -}; - -async function displayProfile(opts) { - /// UTILITY FUNCTIONS - // Sort claims by name and filter for errors - const sortClaims = (claims) => { - claims = claims.filter((a) => (a && a.errors.length == 0 && a.serviceproviderData)); - claims = claims.sort((a,b) => (a.serviceproviderData.serviceprovider.name > b.serviceproviderData.serviceprovider.name) ? 1 : ((b.serviceproviderData.serviceprovider.name > a.serviceproviderData.serviceprovider.name) ? -1 : 0)); - return claims; - } - // Find the primary claims - const getPrimaryClaims = (primaryUserId, userIds, verifications) => { - let primaryClaims = null; - userIds.forEach((userId, i) => { - if (!primaryClaims && userId.userId && userId.userId.email === primaryUserId.email) { - primaryClaims = sortClaims(verifications[i]); - } - }); - return primaryClaims; - } - // Generate a HTML string for the profile header - const generateProfileHeaderHTML = (data) => { - if (!data) { - data = { - profileHide: true, - name: '', - fingerprint: '', - key: { - url: '', - mode: '', - server: '', - id: '', - }, - avatarURL: '/static/img/avatar_placeholder.png', - } - } - - // TODO Add support for custom HKP server - return ` -
- - avatar - -
-
-

${data.name}

-

- ${data.fingerprint} -

-
- - -
-
- `; - } - // Generate a HTML string for each userId and associated claims - const generateProfileUserIdHTML = (userId, claims, dialogIds, opts) => { - // Init output - let output = ''; - - // Add claim header to output - output += `

${userId.email}${opts.isPrimary ? ' primary' : ''}

`; - - // Handle claims identical to primary - if ('isIdenticalToPrimary' in opts && opts.isIdenticalToPrimary) { - - output += ` -
-
-
-

Identical to primary claims

-
-
-
- `; - return output; - } - - // Handle "no claims" - if (claims.length == 0) { - output += ` -
-
-
-

No claims associated

-
-
-
- `; - return output; - } - - claims = sortClaims(claims); - - // Generate output for each claim - claims.forEach((claim, i) => { - const claimData = claim.serviceproviderData; - if (!claimData.serviceprovider.name) { - return; - } - - output += ` -
-
-
-

${claimData.profile.display}

-
- -
-
${claim.isVerified ? "✔" : "✕"}
-
- `; - }); - - return output; - } - // Generate a HTML string for each userId and associated claims - const generateClaimDialogHTML = (claims, dialogIds) => { - // Generate dialog for each claim - let output = ''; - - claims.forEach((claim, i) => { - const claimData = claim.serviceproviderData; - output += ` - -
-

- The claim's service provider is ${claimData.serviceprovider.name}. -

-

- The claim points to: ${claimData.profile.uri}. -

-

- The supposed proof is located at: ${claimData.proof.uri}. -

- `; - if (claimData.proof.fetch) { - output += ` -

- Due to technical restraints, the machine had to fetch the proof from: ${claimData.proof.fetch}. This link may not work for you. -

- `; - } - if (claimData.customRequestHandler) { - output += ` -

- This claim's verification process was more complex than most verifications. The link(s) above may offer only limited insight into the verification process. -

- `; - } - output += ` -

- The claim ${claim.isVerified ? 'has been' : 'could not be'} verified by this proof. -

- `; - if (claim.errors.length > 0) { - output += ` -

- The verification encountered errors: ${JSON.stringify(claim.errors)}. -

- `; - } - output += ` -
- -
-
-
- `; - }); - - return output; - } - - /// MAIN - // Init variables - let keyData, keyLink, sigVerification, sigKeyUri, fingerprint, feedback = "", verifications = []; - - const doipOpts = { - proxyPolicy: 'adaptive', - } - - // Reset the avatar - document.body.querySelector('#profileHeader').src = generateProfileHeaderHTML(null) - - if (opts.mode == 'sig') { - try { - sigVerification = await doip.signatures.verify(opts.input, doipOpts); - keyData = sigVerification.publicKey.data; - fingerprint = sigVerification.publicKey.fingerprint; - } catch (e) { - feedback += `

There was a problem reading the signature.

`; - if ('errors' in e) { - feedback += `${e.errors.join(', ')}`; - } else { - feedback += `${e}`; - } - document.body.querySelector('#profileData').innerHTML = feedback; - document.body.querySelector('#profileName').innerHTML = "Could not load profile"; +// Register modals +document.querySelectorAll('dialog').forEach(function(d) { + dialogPolyfill.registerDialog(d); + d.addEventListener('click', function(ev) { + if (ev && ev.target != d) { return; } - } else { - try { - let keyURI; - if (opts.mode === 'hkp' && opts.server) { - keyURI = `${opts.mode}:${opts.server}:${opts.input}` - } else { - keyURI = `${opts.mode}:${opts.input}` - } - keyData = await doip.keys.fetch.uri(keyURI); - fingerprint = keyData.keyPacket.getFingerprint(); - } catch (e) { - feedback += `

There was a problem fetching the keys.

`; - feedback += `${e}`; - document.body.querySelector('#profileData').innerHTML = feedback; - document.body.querySelector('#profileName').innerHTML = "Could not load profile"; - return; - } - } - - // Get data of primary userId - const userPrimary = await keyData.getPrimaryUser(); - const userData = userPrimary.user.userId; - const userName = userData.name ? userData.name : userData.email; - const userMail = userData.email ? userData.email : null; - - // TODO Get image from user attribute - let imgUri = null; - - // Determine WKD or HKP link - let keyUriMode = opts.mode; - let keyUriServer = null; - let keyUriId = opts.input; - if (opts.mode === 'sig') { - const keyUriMatch = sigVerification.publicKey.uri.match(/([^:]*)(?:\:(.*))?:(.*)/); - keyUriMode = keyUriMatch[1]; - keyUriServer = keyUriMatch[2]; - keyUriId = keyUriMatch[3]; - } - - switch (keyUriMode) { - case "wkd": - const [, localPart, domain] = /(.*)@(.*)/.exec(keyUriId); - const localEncoded = await computeWKDLocalPart(localPart.toLowerCase()); - const urlAdvanced = `https://openpgpkey.${domain}/.well-known/openpgpkey/${domain}/hu/${localEncoded}`; - const urlDirect = `https://${domain}/.well-known/openpgpkey/hu/${localEncoded}`; - - try { - keyLink = await fetch(urlAdvanced).then(function(response) { - if (response.status === 200) { - return urlAdvanced; - } - }); - } catch (e) { - console.warn(e); - } - if (!keyLink) { - try { - keyLink = await fetch(urlDirect).then(function(response) { - if (response.status === 200) { - return urlDirect; - } - }); - } catch (e) { - console.warn(e); - } - } - if (!keyLink) { - keyLink = `https://${keyUriServer ? keyUriServer : 'keys.openpgp.org'}/pks/lookup?op=get&options=mr&search=0x${fingerprint}`; - } - break; - - case "hkp": - keyLink = `https://${keyUriServer ? keyUriServer : 'keys.openpgp.org'}/pks/lookup?op=get&options=mr&search=0x${fingerprint}`; - break; - - case "keybase": - keyLink = opts.keyLink; - break; - } - - // Generate profile header - const profileHash = openpgp.util.str_to_hex(openpgp.util.Uint8Array_to_str(await openpgp.crypto.hash.md5(openpgp.util.str_to_Uint8Array(userData.email)))); - document.body.querySelector('#profileHeader').innerHTML = generateProfileHeaderHTML({ - profileHide: false, - name: userName, - fingerprint: fingerprint, - key: { - url: keyLink, - mode: keyUriMode, - server: keyUriServer, - id: keyUriId, - }, - avatarURL: imgUri ? imgUri : `https://www.gravatar.com/avatar/${profileHash}?s=128&d=mm` - }) - document.title = `${userName} - Keyoxide`; - - try { - if (sigVerification) { - verifications = sigVerification.claims - } else { - verifications = await doip.claims.verify(keyData, fingerprint, doipOpts) - } - } catch (e) { - feedback += `

There was a problem verifying the claims.

`; - feedback += `${e}`; - document.body.querySelector('#profileData').innerHTML = feedback; - document.body.querySelector('#profileName').innerHTML = "Could not load profile"; - return; - } - - // Exit if no notations are available - if (verifications.length == 0) { - return; - } - - let feedbackDialog = ""; - let dialogIds = null; - feedback = ""; - - if (opts.mode === 'sig') { - const claims = sortClaims(verifications); - - dialogIds = new Uint32Array(claims.length); - window.crypto.getRandomValues(dialogIds); - - feedback += generateProfileUserIdHTML(userData, claims, dialogIds, {isPrimary: false}); - feedbackDialog += generateClaimDialogHTML(claims, dialogIds); - } else { - const primaryClaims = getPrimaryClaims(userData, keyData.users, verifications); - - dialogIds = new Uint32Array(primaryClaims.length); - window.crypto.getRandomValues(dialogIds); - - feedback += generateProfileUserIdHTML(userData, primaryClaims, dialogIds, {isPrimary: true}); - feedbackDialog += generateClaimDialogHTML(primaryClaims, dialogIds); - - keyData.users.forEach((user, i) => { - if (!user.userId || userData.email && user.userId && user.userId.email === userData.email) { - return; - } - - const claims = sortClaims(verifications[i]) - const opts = { - isPrimary: false, - isIdenticaltoPrimary: primaryClaims && primaryClaims.toString() === claims.toString() - } - - dialogIds = new Uint32Array(claims.length); - window.crypto.getRandomValues(dialogIds); - - feedback += generateProfileUserIdHTML(user.userId, claims, dialogIds, opts); - feedbackDialog += generateClaimDialogHTML(claims, dialogIds); - }) - } - - feedback += ` - -
-

- Keyoxide allows anyone to prove that they have accounts on certain websites and, by doing so, establish an online identity. To guarantee the validity of these identity verifications and prevent impersonation, Keyoxide uses secure and well-known encryption paradigms. All claims are verified using bidirectional linking. -

-

- You are currently viewing someone's Keyoxide profile, including the verification results of their identity claims. -

-

- More detailed information is available on the What is Keyoxide page. -

-
- -
-
-
- -
-

- The profile page you are currently viewing depends at least partially on a server you may not know, operated by someone you may not have reason to trust. -

-

- You can choose to perform the identity verification again, but this time completely locally, removing the need to trust unknown servers. -

-

- On linux/mac/windows, run: -

-
keyoxide verify ${opts.mode}:${opts.input}
-
- -
-
-
- `; - - // Display feedback - document.body.querySelector('#profileProofs').innerHTML = feedback; - if (feedbackDialog) { - document.body.querySelector('#profileDialogs').innerHTML = feedbackDialog; - } - - // Register modals - document.querySelectorAll('dialog').forEach(function(d) { - dialogPolyfill.registerDialog(d); - d.addEventListener('click', function(ev) { - if (ev && ev.target != d) { - return; - } - d.close(); - }); + d.close(); }); +}); - // Register form listeners - const elFormEncrypt = document.body.querySelector("#dialog--encryptMessage form"); - elFormEncrypt.onsubmit = async function (evt) { - evt.preventDefault(); +// let elFormSignatureProfile = document.body.querySelector("#formGenerateSignatureProfile"), +// elProfileUid = document.body.querySelector("#profileUid"), +// elProfileMode = document.body.querySelector("#profileMode"), +// elProfileServer = document.body.querySelector("#profileServer"), +// elModeSelect = document.body.querySelector("#modeSelect"), +// elUtilWKD = document.body.querySelector("#form-util-wkd"), +// elUtilQRFP = document.body.querySelector("#form-util-qrfp"), +// elUtilQR = document.body.querySelector("#form-util-qr"), +// elUtilProfileURL = document.body.querySelector("#form-util-profile-url"); - try { - // Encrypt the message - encrypted = await openpgp.encrypt({ - message: openpgp.message.fromText(elFormEncrypt.querySelector('.input').value), - publicKeys: keyData - }); - elFormEncrypt.querySelector('.output').value = encrypted.data; - } catch (e) { - console.error(e); - elFormEncrypt.querySelector('.output').value = `Could not encrypt message!\n==========================\n${e.message ? e.message : e}`; - } - }; - const elFormVerify = document.body.querySelector("#dialog--verifySignature form"); - elFormVerify.onsubmit = async function (evt) { - evt.preventDefault(); +// if (elModeSelect) { +// elModeSelect.onchange = function (evt) { +// let elAllModes = document.body.querySelectorAll('.modes'); +// elAllModes.forEach(function(el) { +// el.classList.remove('modes--visible'); +// }); +// document.body.querySelector(`.modes--${elModeSelect.value}`).classList.add('modes--visible'); +// } +// elModeSelect.dispatchEvent(new Event("change")); +// } - try { - // Try two different methods of signature reading - let signature = null, verified = null, readError = null; - try { - signature = await openpgp.message.readArmored(elFormVerify.querySelector('.input').value); - } catch(e) { - readError = e; - } - try { - signature = await openpgp.cleartext.readArmored(elFormVerify.querySelector('.input').value); - } catch(e) { - readError = e; - } - if (signature == null) { throw(readError) }; +// if (elProfileUid) { +// let opts, profileUid = elProfileUid.innerHTML; +// switch (elProfileMode.innerHTML) { +// default: +// case "sig": +// elFormSignatureProfile.onsubmit = function (evt) { +// evt.preventDefault(); - // Verify the signature - verified = await openpgp.verify({ - message: signature, - publicKeys: keyData - }); +// opts = { +// input: document.body.querySelector("#plaintext_input").value, +// mode: elProfileMode.innerHTML +// } - if (verified.signatures[0].valid) { - elFormVerify.querySelector('.output').value = `The message was signed by the profile's key.`; - } else { - elFormVerify.querySelector('.output').value = `The message was NOT signed by the profile's key.`; - } - } catch (e) { - console.error(e); - elFormVerify.querySelector('.output').value = `Could not verify signature!\n===========================\n${e.message ? e.message : e}`; - } - }; -} +// displayProfile(opts) +// } +// break; -async function fetchKeys(opts) { - // Init - let lookupOpts, wkd, hkd, sig, lastPrimarySig; - let output = { - publicKey: null, - user: null, - notations: null, - sigKeyId: null, - sigUserId: null, - sigContent: null - }; +// case "auto": +// if (/.*@.*/.test(profileUid)) { +// // Match email for wkd +// opts = { +// input: profileUid, +// mode: "wkd" +// } +// } else { +// // Match fingerprint for hkp +// opts = { +// input: profileUid, +// mode: "hkp" +// } +// } +// break; - // Autodetect mode - if (opts.mode == "auto") { - if (/.*@.*\..*/.test(opts.input)) { - opts.mode = "wkd"; - } else { - opts.mode = "hkp"; - } - } +// case "hkp": +// opts = { +// input: profileUid, +// server: elProfileServer.innerHTML, +// mode: elProfileMode.innerHTML +// } +// break; - // Fetch keys depending on the input mode - switch (opts.mode) { - case "plaintext": - output.publicKey = (await openpgp.key.readArmored(opts.input)).keys[0]; +// case "wkd": +// opts = { +// input: profileUid, +// mode: elProfileMode.innerHTML +// } +// break; - if (!output.publicKey) { - throw("Error: No public keys could be fetched from the plaintext input."); - } - break; +// case "keybase": +// let match = profileUid.match(/(.*)\/(.*)/); +// opts = { +// username: match[1], +// fingerprint: match[2], +// mode: elProfileMode.innerHTML +// } +// break; +// } - case "wkd": - wkd = new openpgp.WKD(); - lookupOpts = { - email: opts.input - }; - output.publicKey = (await wkd.lookup(lookupOpts)).keys[0]; +// if (elProfileMode.innerHTML !== 'sig') { +// keyoxide.displayProfile(opts); +// } +// } - if (!output.publicKey) { - throw("Error: No public keys could be fetched using WKD."); - } - break; +// if (elUtilWKD) { +// elUtilWKD.onsubmit = function (evt) { +// evt.preventDefault(); +// } - case "hkp": - if (!opts.server) {opts.server = "https://keys.openpgp.org/"}; - hkp = new openpgp.HKP(opts.server); - lookupOpts = { - query: opts.input - }; - output.publicKey = await hkp.lookup(lookupOpts); - output.publicKey = (await openpgp.key.readArmored(output.publicKey)).keys[0]; +// const elInput = document.body.querySelector("#input"); +// const elOutput = document.body.querySelector("#output"); +// const elOutputDirect = document.body.querySelector("#output_url_direct"); +// const elOutputAdvanced = document.body.querySelector("#output_url_advanced"); +// let match; - if (!output.publicKey) { - throw("Error: No public keys could be fetched from the HKP server."); - } - break; +// elInput.addEventListener("input", async function(evt) { +// if (evt.target.value) { +// if (/(.*)@(.{1,}\..{1,})/.test(evt.target.value)) { +// match = evt.target.value.match(/(.*)@(.*)/); +// elOutput.innerText = await computeWKDLocalPart(match[1]); +// elOutputDirect.innerText = `https://${match[2]}/.well-known/openpgpkey/hu/${elOutput.innerText}?l=${match[1]}`; +// elOutputAdvanced.innerText = `https://openpgpkey.${match[2]}/.well-known/openpgpkey/${match[2]}/hu/${elOutput.innerText}?l=${match[1]}`; +// } else { +// elOutput.innerText = await computeWKDLocalPart(evt.target.value); +// elOutputDirect.innerText = "Waiting for input"; +// elOutputAdvanced.innerText = "Waiting for input"; +// } +// } else { +// elOutput.innerText = "Waiting for input"; +// elOutputDirect.innerText = "Waiting for input"; +// elOutputAdvanced.innerText = "Waiting for input"; +// } +// }); - case "keybase": - opts.keyLink = `https://keybase.io/${opts.username}/pgp_keys.asc?fingerprint=${opts.fingerprint}`; - opts.input = `${opts.username}/${opts.fingerprint}`; - try { - opts.plaintext = await fetch(opts.keyLink).then(function(response) { - if (response.status === 200) { - return response; - } - }) - .then(response => response.text()); - } catch (e) { - throw(`Error: No public keys could be fetched from the Keybase account (${e}).`); - } - output.publicKey = (await openpgp.key.readArmored(opts.plaintext)).keys[0]; +// elInput.dispatchEvent(new Event("input")); +// } - if (!output.publicKey) { - throw("Error: No public keys could be read from the Keybase account."); - } - break; +// if (elUtilQRFP) { +// elUtilQRFP.onsubmit = function (evt) { +// evt.preventDefault(); +// } - case "signature": - sig = (await openpgp.signature.readArmored(opts.signature)); - if ('compressed' in sig.packets[0]) { - sig = sig.packets[0]; - output.sigContent = (await openpgp.stream.readToEnd(await sig.packets[1].getText())); - }; - output.sigUserId = sig.packets[0].signersUserId; - output.sigKeyId = (await sig.packets[0].issuerKeyId.toHex()); +// const qrTarget = document.getElementById('qrcode'); +// const qrContext = qrTarget.getContext('2d'); +// const qrOpts = { +// errorCorrectionLevel: 'H', +// margin: 1, +// width: 256, +// height: 256 +// }; - if (!opts.server) {opts.server = "https://keys.openpgp.org/"}; - hkp = new openpgp.HKP(opts.server); - lookupOpts = { - query: output.sigUserId ? output.sigUserId : output.sigKeyId - }; - output.publicKey = await hkp.lookup(lookupOpts); - output.publicKey = (await openpgp.key.readArmored(output.publicKey)).keys[0]; +// const elInput = document.body.querySelector("#input"); - if (!output.publicKey) { - throw("Error: No public keys could be extracted from the signature."); - } - break; - } +// elInput.addEventListener("input", async function(evt) { +// if (evt.target.value) { +// QRCode.toCanvas(qrTarget, evt.target.value, qrOpts, function (error) { +// if (error) { +// qrContext.clearRect(0, 0, qrTarget.width, qrTarget.height); +// console.error(error); +// } +// }); +// } else { +// qrContext.clearRect(0, 0, qrTarget.width, qrTarget.height); +// } +// }); - // Gather more data about the primary key and user - output.fingerprint = await output.publicKey.primaryKey.getFingerprint(); - output.user = await output.publicKey.getPrimaryUser(); - lastPrimarySig = output.user.selfCertification; - output.notations = lastPrimarySig.notations || []; +// elInput.dispatchEvent(new Event("input")); +// } - return output; -} +// if (elUtilQR) { +// elUtilQR.onsubmit = function (evt) { +// evt.preventDefault(); +// } -async function computeWKDLocalPart(message) { - const data = openpgp.util.str_to_Uint8Array(message); - const hash = await openpgp.crypto.hash.sha1(data); - return openpgp.util.encodeZBase32(hash); -} +// const qrTarget = document.getElementById('qrcode'); +// const qrContext = qrTarget.getContext('2d'); +// const qrOpts = { +// errorCorrectionLevel: 'L', +// margin: 1, +// width: 256, +// height: 256 +// }; -async function generateProfileURL(data) { - let hostname = window.location.hostname; +// const elInput = document.body.querySelector("#input"); - if (data.input == "") { - return "Waiting for input..."; - } - switch (data.source) { - case "wkd": - return `https://${hostname}/${data.input}`; - break; - case "hkp": - if (/.*@.*\..*/.test(data.input)) { - return `https://${hostname}/hkp/${data.input}`; - } else { - return `https://${hostname}/${data.input}`; - } - break; - case "keybase": - const re = /https\:\/\/keybase.io\/(.*)\/pgp_keys\.asc\?fingerprint\=(.*)/; - if (!re.test(data.input)) { - return "Incorrect Keybase public key URL."; - } - const match = data.input.match(re); - return `https://${hostname}/keybase/${match[1]}/${match[2]}`; - break; - } -} +// if (elInput.innerText) { +// elInput.innerText = decodeURIComponent(elInput.innerText); -async function fetchWithTimeout(url, timeout = 3000) { - return Promise.race([ - fetch(url), - new Promise((_, reject) => - setTimeout(() => reject(new Error('timeout')), timeout) - ) - ]); -} +// QRCode.toCanvas(qrTarget, elInput.innerText, qrOpts, function (error) { +// if (error) { +// document.body.querySelector("#qrcode--altLink").href = "#"; +// qrContext.clearRect(0, 0, qrTarget.width, qrTarget.height); +// console.error(error); +// } else { +// document.body.querySelector("#qrcode--altLink").href = elInput.innerText; +// } +// }); +// } else { +// qrContext.clearRect(0, 0, qrTarget.width, qrTarget.height); +// } +// } -// General purpose -let elFormSignatureProfile = document.body.querySelector("#formGenerateSignatureProfile"), - elProfileUid = document.body.querySelector("#profileUid"), - elProfileMode = document.body.querySelector("#profileMode"), - elProfileServer = document.body.querySelector("#profileServer"), - elModeSelect = document.body.querySelector("#modeSelect"), - elUtilWKD = document.body.querySelector("#form-util-wkd"), - elUtilQRFP = document.body.querySelector("#form-util-qrfp"), - elUtilQR = document.body.querySelector("#form-util-qr"), - elUtilProfileURL = document.body.querySelector("#form-util-profile-url"); +// if (elUtilProfileURL) { +// elUtilProfileURL.onsubmit = function (evt) { +// evt.preventDefault(); +// } -if (elModeSelect) { - elModeSelect.onchange = function (evt) { - let elAllModes = document.body.querySelectorAll('.modes'); - elAllModes.forEach(function(el) { - el.classList.remove('modes--visible'); - }); - document.body.querySelector(`.modes--${elModeSelect.value}`).classList.add('modes--visible'); - } - elModeSelect.dispatchEvent(new Event("change")); -} +// const elInput = document.body.querySelector("#input"), +// elSource = document.body.querySelector("#source"), +// elOutput = document.body.querySelector("#output"); -if (elProfileUid) { - let opts, profileUid = elProfileUid.innerHTML; - switch (elProfileMode.innerHTML) { - default: - case "sig": - elFormSignatureProfile.onsubmit = function (evt) { - evt.preventDefault(); +// let data = { +// input: elInput.value, +// source: elSource.value +// }; - opts = { - input: document.body.querySelector("#plaintext_input").value, - mode: elProfileMode.innerHTML - } +// elInput.addEventListener("input", async function(evt) { +// data = { +// input: elInput.value, +// source: elSource.value +// }; +// elOutput.innerText = await generateProfileURL(data); +// }); - displayProfile(opts) - } - break; +// elSource.addEventListener("input", async function(evt) { +// data = { +// input: elInput.value, +// source: elSource.value +// }; +// elOutput.innerText = await generateProfileURL(data); +// }); - case "auto": - if (/.*@.*/.test(profileUid)) { - // Match email for wkd - opts = { - input: profileUid, - mode: "wkd" - } - } else { - // Match fingerprint for hkp - opts = { - input: profileUid, - mode: "hkp" - } - } - break; - - case "hkp": - opts = { - input: profileUid, - server: elProfileServer.innerHTML, - mode: elProfileMode.innerHTML - } - break; - - case "wkd": - opts = { - input: profileUid, - mode: elProfileMode.innerHTML - } - break; - - case "keybase": - let match = profileUid.match(/(.*)\/(.*)/); - opts = { - username: match[1], - fingerprint: match[2], - mode: elProfileMode.innerHTML - } - break; - } - - if (elProfileMode.innerHTML !== 'sig') { - displayProfile(opts); - } -} - -if (elUtilWKD) { - elUtilWKD.onsubmit = function (evt) { - evt.preventDefault(); - } - - const elInput = document.body.querySelector("#input"); - const elOutput = document.body.querySelector("#output"); - const elOutputDirect = document.body.querySelector("#output_url_direct"); - const elOutputAdvanced = document.body.querySelector("#output_url_advanced"); - let match; - - elInput.addEventListener("input", async function(evt) { - if (evt.target.value) { - if (/(.*)@(.{1,}\..{1,})/.test(evt.target.value)) { - match = evt.target.value.match(/(.*)@(.*)/); - elOutput.innerText = await computeWKDLocalPart(match[1]); - elOutputDirect.innerText = `https://${match[2]}/.well-known/openpgpkey/hu/${elOutput.innerText}?l=${match[1]}`; - elOutputAdvanced.innerText = `https://openpgpkey.${match[2]}/.well-known/openpgpkey/${match[2]}/hu/${elOutput.innerText}?l=${match[1]}`; - } else { - elOutput.innerText = await computeWKDLocalPart(evt.target.value); - elOutputDirect.innerText = "Waiting for input"; - elOutputAdvanced.innerText = "Waiting for input"; - } - } else { - elOutput.innerText = "Waiting for input"; - elOutputDirect.innerText = "Waiting for input"; - elOutputAdvanced.innerText = "Waiting for input"; - } - }); - - elInput.dispatchEvent(new Event("input")); -} - -if (elUtilQRFP) { - elUtilQRFP.onsubmit = function (evt) { - evt.preventDefault(); - } - - const qrTarget = document.getElementById('qrcode'); - const qrContext = qrTarget.getContext('2d'); - const qrOpts = { - errorCorrectionLevel: 'H', - margin: 1, - width: 256, - height: 256 - }; - - const elInput = document.body.querySelector("#input"); - - elInput.addEventListener("input", async function(evt) { - if (evt.target.value) { - QRCode.toCanvas(qrTarget, evt.target.value, qrOpts, function (error) { - if (error) { - qrContext.clearRect(0, 0, qrTarget.width, qrTarget.height); - console.error(error); - } - }); - } else { - qrContext.clearRect(0, 0, qrTarget.width, qrTarget.height); - } - }); - - elInput.dispatchEvent(new Event("input")); -} - -if (elUtilQR) { - elUtilQR.onsubmit = function (evt) { - evt.preventDefault(); - } - - const qrTarget = document.getElementById('qrcode'); - const qrContext = qrTarget.getContext('2d'); - const qrOpts = { - errorCorrectionLevel: 'L', - margin: 1, - width: 256, - height: 256 - }; - - const elInput = document.body.querySelector("#input"); - - if (elInput.innerText) { - elInput.innerText = decodeURIComponent(elInput.innerText); - - QRCode.toCanvas(qrTarget, elInput.innerText, qrOpts, function (error) { - if (error) { - document.body.querySelector("#qrcode--altLink").href = "#"; - qrContext.clearRect(0, 0, qrTarget.width, qrTarget.height); - console.error(error); - } else { - document.body.querySelector("#qrcode--altLink").href = elInput.innerText; - } - }); - } else { - qrContext.clearRect(0, 0, qrTarget.width, qrTarget.height); - } -} - -if (elUtilProfileURL) { - elUtilProfileURL.onsubmit = function (evt) { - evt.preventDefault(); - } - - const elInput = document.body.querySelector("#input"), - elSource = document.body.querySelector("#source"), - elOutput = document.body.querySelector("#output"); - - let data = { - input: elInput.value, - source: elSource.value - }; - - elInput.addEventListener("input", async function(evt) { - data = { - input: elInput.value, - source: elSource.value - }; - elOutput.innerText = await generateProfileURL(data); - }); - - elSource.addEventListener("input", async function(evt) { - data = { - input: elInput.value, - source: elSource.value - }; - elOutput.innerText = await generateProfileURL(data); - }); - - elInput.dispatchEvent(new Event("input")); -} - -function capitalizeLetteredServices(serviceName) { - const servName = serviceName.toLowerCase(); - if (servName === 'dns' || servName === 'xmpp' || servName === 'irc') { - return servName.toUpperCase(); - } - return serviceName; -} +// elInput.dispatchEvent(new Event("input")); +// } diff --git a/static/styles.css b/static/styles.css index 2d2932a..7d56310 100644 --- a/static/styles.css +++ b/static/styles.css @@ -126,12 +126,14 @@ section.profile p, .demo p { } .card--profileHeader { display: flex; + flex-direction: column; flex-wrap: wrap; - gap: 2rem; + align-items: center; + gap: 24px; /* text-align: center; */ } .card--profileHeader p, .card--profileHeader small { - margin: 0 0 1.2rem; + margin: 0; } .card--small-profile { display: flex; @@ -172,7 +174,7 @@ section.profile p, .demo p { color: #fff; } #profileName { - font-size: 2rem; + font-size: 1.6rem; color: var(--grey-700); } #profileURLFingerprint { @@ -248,96 +250,21 @@ section.profile p, .demo p { margin-bottom: 0; } -.claim { - display: flex; - align-items: center; - width: 100%; - margin: 0.8rem 0; - padding: 0.8rem 1.2rem; - background-color: var(--purple-100); - border-radius: 8px; +kx-claim { + display: block; + margin: 12px 0; } -.claim p { - margin: 0; -} -.claim .claim__main { - flex: 1; -} -.claim .claim__description p { - font-size: 1.4rem; - line-height: 2rem; -} -.claim .claim__links p, p.subtle-links { - display: flex; - align-items: center; - flex-wrap: wrap; - font-size: 1rem; - color: var(--grey-700); -} -.claim .claim__links a, .claim .claim__links span, p.subtle-links a { - font-size: 1rem; - margin: 0 10px 0 0; - color: var(--grey-700); -} -/* p.subtle-links a:first-of-type { - margin: 0; -} */ -.claim .serviceProvider { - color: var(--grey-500); -} -.claim .claim__verification { - display: flex; - align-items: center; - justify-content: center; - min-width: 48px; - height: 48px; - border-radius: 100%; - background-color: var(--red-600); - color: #fff; - font-size: 2rem; - user-select: none; -} -.claim .claim__verification--true { - background-color: var(--green-600); -} -@media screen and (max-width: 640px) { - .claim .claim__description p { - font-size: 1.2rem; - } - .claim .claim__links a, p.subtle-links a { - font-size: 0.9rem; - } -} -@media screen and (max-width: 480px) { - .claim .claim__description p { - font-size: 1rem; - } - .claim .claim__verification { - min-width: 36px; - height: 36px; - font-size: 1.6rem; - } -} - -/* .demo { - text-align: center; - margin: 9.6rem 0; - font-size: 1.6rem; -} -.demo .claim { - margin: 1.6rem 0; -} */ .avatar { display: inline-block; min-width: 96px; max-width: 128px; - /* margin-top: 1.6rem; */ + line-height: 0; text-align: center; } .avatar img { width: 100%; - border-radius: 24px; + border-radius: 50%; } /* .buttons { diff --git a/views/profile-failed.pug b/views/profile-failed.pug new file mode 100644 index 0000000..268d181 --- /dev/null +++ b/views/profile-failed.pug @@ -0,0 +1,9 @@ +extends templates/base.pug + +block content + section.profile.narrow + h2 Something went wrong when generating the profile + + ul + each error in data.errors + li= error diff --git a/views/profile.pug b/views/profile.pug index e183ad6..a6d8980 100644 --- a/views/profile.pug +++ b/views/profile.pug @@ -1,9 +1,19 @@ extends templates/base.pug +mixin generateUser(user, isPrimary) + h2 + | #{user.userData.email} + if isPrimary + small.primary primary + each claim in user.claims + kx-claim(data-claim=claim) + block js + script(type='application/javascript' src='/static/dialog-polyfill.js' charset='utf-8') script(type='application/javascript' src='/static/openpgp.min.js' charset='utf-8') script(type='application/javascript' src='/static/doip.js' charset='utf-8') - script(type='application/javascript' src='/static/dialog-polyfill.js' charset='utf-8') + script(type='application/javascript' src='/static/kx-claim.js' charset='utf-8') + script(type='application/javascript' src='/static/kx-key.js' charset='utf-8') script(type='application/javascript' src='/static/scripts.js' charset='utf-8') block css @@ -13,9 +23,6 @@ block content section.profile.narrow noscript p Keyoxide requires JavaScript to function. - span#profileUid(style='display: none;') #{uid} - span#profileServer(style='display: none;') #{server} - span#profileMode(style='display: none;') #{mode} dialog#dialog--encryptMessage div @@ -35,19 +42,23 @@ block content form(method="dialog") input(type="submit" value="Close") - #profileDialogs - - if (mode == 'sig') - #profileSigInput.card.card--form - form#formGenerateSignatureProfile(method='post') - label(for="plaintext_input") Please enter the raw profile signature below and press "Generate profile". - textarea#plaintext_input(name='plaintext_input') - input(type='submit', name='submit', value='Generate profile') - #profileHeader.card.card--profileHeader + a.avatar(href="#") + img#profileAvatar(src=data.extra.avatarURL alt="avatar") + + p#profileName= data.keyData.users[data.keyData.primaryUserIndex].userData.name + //- p#profileURLFingerprint + //- a(href=data.key.fetchURL)=data.keyData.fingerprint + .buttons + button(onClick="document.querySelector('#dialog--encryptMessage').showModal();") Encrypt message + button(onClick="document.querySelector('#dialog--verifySignature').showModal();") Verify signature #profileProofs.card - if (mode == 'sig') - //- p Waiting for input… - else - p Loading keys & verifying proofs… + h2 Key + kx-key(data-keydata=data.keyData) + + +generateUser(data.keyData.users[data.keyData.primaryUserIndex], true) + each user, index in data.keyData.users + unless index == data.keyData.primaryUserIndex + +generateUser(user, false) +