├── test ├── .mocharc.js ├── setup.mjs ├── fixtures │ ├── key2.asc │ ├── key1.asc │ ├── key5.asc │ ├── key6.asc │ ├── key4.asc │ └── key3.asc ├── integration │ ├── email-test.js │ ├── mongo-test.js │ └── server-test.js └── unit │ ├── email-test.js │ ├── util-test.js │ └── pgp-test.js ├── src ├── view │ ├── footer.html │ ├── removal-success.html │ ├── verify-success.html │ ├── layout.html │ ├── key-armored.html │ ├── index.html │ └── manage.html ├── index.js ├── lib │ ├── csp.js │ ├── templates.js │ ├── log.js │ ├── util.js │ └── closure-library │ │ ├── LICENSE │ │ └── closure │ │ └── goog │ │ └── emailaddress.js ├── route │ ├── www.js │ ├── rest.js │ └── hkp.js ├── static │ ├── js │ │ └── manage.js │ └── css │ │ └── jumbotron-narrow.css ├── server.js ├── tools │ └── clean.js └── modules │ ├── email.js │ ├── mongo.js │ ├── pgp.js │ ├── purify-key.js │ └── public-key.js ├── .gitignore ├── locales ├── en.json └── de.json ├── Changelog.md ├── package.json ├── config └── config.js ├── eslint.config.mjs └── README.md /test/.mocharc.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | module.exports = { 5 | recursive: true, 6 | require: ['./test/setup.js'] 7 | }; 8 | -------------------------------------------------------------------------------- /test/setup.mjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as chai from 'chai'; 4 | import chaiAsPromised from 'chai-as-promised'; 5 | import sinon from 'sinon'; 6 | 7 | import log from '../src/lib/log.js'; 8 | 9 | log.silent = false; 10 | 11 | chai.use(chaiAsPromised); 12 | 13 | global.expect = chai.expect; 14 | global.sinon = sinon; 15 | -------------------------------------------------------------------------------- /src/view/footer.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /src/view/removal-success.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |

Mailvelope Key Server

5 |
6 | 7 |
8 |
9 |

<%= __('removal_success', {email}) %>

10 |
11 |
12 | 13 | <%- include('footer.html') %> 14 | 15 |
16 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2023 Mailvelope GmbH 3 | * Licensed under the GNU Affero General Public License version 3 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const init = require('./server'); 9 | const log = require('./lib/log'); 10 | 11 | (async () => { 12 | const server = await init(); 13 | log.info('Server running on %s', server.info.uri); 14 | })(); 15 | 16 | process.on('unhandledRejection', err => { 17 | console.log(err); 18 | process.exit(1); 19 | }); 20 | -------------------------------------------------------------------------------- /src/view/verify-success.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |

Mailvelope Key Server

5 |
6 | 7 |
8 |
9 |

<%= __('verify_success_header', {email}) %>

10 |

<%= __('verify_success_link') %> <%= link %>

11 |
12 |
13 | 14 | <%- include('footer.html') %> 15 | 16 |
17 | -------------------------------------------------------------------------------- /src/lib/csp.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2020 Mailvelope GmbH 3 | * Licensed under the GNU Affero General Public License version 3 4 | */ 5 | 6 | 'use strict'; 7 | 8 | exports.plugin = { 9 | name: 'CSP', 10 | async register(server) { 11 | server.ext('onPreResponse', async (request, h) => { 12 | const {response} = request; 13 | if (!response.isBoom) { 14 | response.header('Content-Security-Policy', "default-src 'self'; object-src 'none'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; form-action 'self'; base-uri 'self';"); 15 | } 16 | return h.continue; 17 | }); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/view/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Mailvelope Key Server 9 | 10 | 11 | 12 | 13 | <%- content %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/fixtures/key2.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | xjMEZR7BxRYJKwYBBAHaRw8BAQdAmQqZ2+JpXmfpvFc8Yq05KR45oie+RDff 4 | 8xij0EM74TLNJU1haWx2ZWxvcGUgRGVtbyA8ZGVtb0BtYWlsdmVsb3BlLmNv 5 | bT7CkgQQFgoARAWCZR7BxQWJCWNegAQLCQcICZBMA6RzYsW0zAMVCAoEFgAC 6 | AQIZAQKbAwIeARYhBJBQf7IpZY9x896WqEwDpHNixbTMAADtjwEA9dmFPQXv 7 | jz8HAOsAq6pBkY8qWHiy2EC6xoVkIOo0dEYA/j5zdc4XyPV6HtVari8GdUCq 8 | A9kuKWNw/HQ+n2K06DcLzjgEZR7BxRIKKwYBBAGXVQEFAQEHQC5PC3ZxJJmY 9 | YvUG+GTwHnt66j6c3/2Pvg69joY0Pg0JAwEIB8J+BBgWCAAwBYJlHsHFBYkJ 10 | Y16ACZBMA6RzYsW0zAKbDBYhBJBQf7IpZY9x896WqEwDpHNixbTMAADSBQEA 11 | 9JYBcb0UTTRKguoQyGIhRill7OaAzEvgP3LG6ioJsvwBAK1dxtmtymDe+7Wq 12 | bizTYpcFPbggOxV2dMWsIfDauAIB 13 | =mqy9 14 | -----END PGP PUBLIC KEY BLOCK----- 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directory 30 | node_modules 31 | 32 | # Optional npm cache directory 33 | .npm 34 | 35 | # Optional REPL history 36 | .node_repl_history 37 | 38 | .env 39 | -------------------------------------------------------------------------------- /src/lib/templates.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const util = require('./util'); 4 | 5 | function verifyKey({name, email, nonce, origin, keyId, i18n}) { 6 | const link = `${util.url(origin)}/api/v1/key?op=verify&keyId=${keyId}&nonce=${nonce}`; 7 | return { 8 | subject: i18n.__('verify_key_subject'), 9 | text: i18n.__mf('verify_key_text', {name, email, link, host: origin.host}) 10 | }; 11 | } 12 | 13 | function verifyRemove({name, email, nonce, origin, keyId, i18n}) { 14 | const link = `${util.url(origin)}/api/v1/key?op=verifyRemove&keyId=${keyId}&nonce=${nonce}`; 15 | return { 16 | subject: i18n.__('verify_removal_subject'), 17 | text: i18n.__mf('verify_removal_text', {name, email, link, host: origin.host}) 18 | }; 19 | } 20 | 21 | module.exports = {verifyKey, verifyRemove}; 22 | -------------------------------------------------------------------------------- /src/lib/log.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2020 Mailvelope GmbH 3 | * Licensed under the GNU Affero General Public License version 3 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const winston = require('winston'); 9 | require('winston-syslog').Syslog; 10 | const config = require('../../config/config'); 11 | 12 | const logger = winston.createLogger({ 13 | level: config.log.level, 14 | levels: winston.config.syslog.levels, 15 | format: winston.format.combine( 16 | winston.format.splat(), 17 | winston.format(info => { 18 | info.message = `${info.message}\n`; 19 | return info; 20 | })(), 21 | winston.format.simple() 22 | ), 23 | exitOnError: false, 24 | transports: [ 25 | config.syslog.host ? new winston.transports.Syslog(config.syslog) : new winston.transports.Console() 26 | ] 27 | }); 28 | 29 | module.exports = logger; 30 | -------------------------------------------------------------------------------- /src/view/key-armored.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 11 |

Mailvelope Key Server

12 |
13 | 14 |
15 |
16 |

<%= query.email ? `Email: ${query.email}` : query.fingerprint ? `Fingerprint: ${query.fingerprint}` : `Key ID: ${query.keyId}` %>

17 |
<%= key.publicKeyArmored %>
18 |
19 |
20 | 21 | <%- include('footer.html') %> 22 | 23 |
24 | -------------------------------------------------------------------------------- /src/route/www.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2020 Mailvelope GmbH 3 | * Licensed under the GNU Affero General Public License version 3 4 | */ 5 | 6 | 'use strict'; 7 | 8 | exports.plugin = { 9 | name: 'www', 10 | async register(server, options) { 11 | const routeOptions = { 12 | security: options.server.security 13 | }; 14 | 15 | server.route({ 16 | method: 'GET', 17 | path: '/', 18 | handler: { 19 | view: 'index' 20 | }, 21 | options: routeOptions 22 | }); 23 | 24 | server.route({ 25 | method: 'GET', 26 | path: '/index.html', 27 | handler(request, h) { 28 | return h.redirect('/').permanent(); 29 | }, 30 | options: routeOptions 31 | }); 32 | 33 | server.route({ 34 | method: 'GET', 35 | path: '/manage.html', 36 | handler: { 37 | view: 'manage' 38 | }, 39 | options: routeOptions 40 | }); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "key_not_found": "Key not found", 3 | "removal_success": "Email address {{email}} removed from the key directory", 4 | "verify_key_comment": "Use Mailvelope/PGP to decrypt the message and click the link", 5 | "verify_key_subject": "Verify your email address", 6 | "verify_key_text": "Hello {name},\n\nplease verify your email address {email} by clicking on the following link:\n\n{link}\n\nAfter verification of your email address, your public key is available in our key directory.\n\nYou can find more info at {host}.\n\nGreetings from the Mailvelope Team", 7 | "verify_removal_subject": "Verify key removal", 8 | "verify_removal_text": "Hello {name},\n\nplease verify removal of your email address {email} from our key server ({host}) by clicking on the following link:\n\n{link}\n\nGreetings from the Mailvelope Team", 9 | "verify_success_header": "Email address {{email}} successfully verified", 10 | "verify_success_link": "Your public OpenPGP key is now available at the following link:" 11 | } 12 | -------------------------------------------------------------------------------- /locales/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "key_not_found": "Schlüssel nicht gefunden", 3 | "removal_success": "E-Mail Adresse {{email}} aus dem Schlüssel Verzeichnis entfernt", 4 | "verify_key_comment": "Mit Mailvelope/PGP entschlüsseln und dann den Link klicken", 5 | "verify_key_subject": "Bestätigen Sie Ihre E-Mail-Adresse", 6 | "verify_key_text": "Hallo {name},\n\nbitte bestätigen Sie Ihre E-Mail-Adresse {email}.\nKlicken Sie hierzu auf den folgenden Link:\n\n{link}\n\nNach der Bestätigung Ihrer E-Mail-Adresse ist ihr öffentlicher Schlüssel in unserem Schlüssel Verzeichnis verfügbar.\n\nWeitere Informationen finden Sie unter {host}.\n\nIhr Mailvelope Team", 7 | "verify_removal_subject": "Entfernen Ihres Schlüssels bestätigen", 8 | "verify_removal_text": "Hallo {name},\n\nbitte bestätigen Sie das Entfernen Ihrer E-Mail-Adresse {email} von unserem Key Server ({host}).\nKlicken Sie hierzu auf den folgenden Link:\n\n{link}\n\nIhr Mailvelope Team", 9 | "verify_success_header": "E-Mail Adresse {{email}} erfolgreich verifiziert", 10 | "verify_success_link": "Ihr öffentlicher OpenPGP Schlüssel ist ab jetzt unter folgendem Link verfügbar:" 11 | } 12 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | Mailvelope Key Server Changelog 2 | =============================== 3 | 4 | v4.0.0 5 | ------- 6 | __Nov 3, 2023__ 7 | * Migrate from koa to hapi and major refactoring 8 | * Upgrade OpenPGP.js to v5 9 | * Load configuration from .env file 10 | * Update dependencies 11 | 12 | v3.0.0 13 | ------- 14 | __Mar 4, 2019__ 15 | * Add upload, update and removal for single user IDs 16 | * Migrate to koa 2 with async/await instead of generators 17 | * Use eslint instead of jscs/jshint 18 | * Use winston instead of npmlog 19 | * Use co-body directly instead of koa-body 20 | * Send email message with PGP inline not PGP/MIME 21 | * Use OpenPGP.js directly instead of nodemailer-openpgp plugin 22 | * Use native ES6 string templates instead of nodemailer template engine 23 | * Update OpenPGP.js to 4.4 and other dependencies 24 | 25 | v2.0.0 26 | ------- 27 | __Aug 15, 2017__ 28 | 29 | * Add release npm script for travis deployment 30 | * Use eslint instead of jscs/jshint 31 | * Use ES6 destructuring 32 | * Replace grunt with npm scripts 33 | * Update dependencies 34 | 35 | v1.0.0 36 | ------- 37 | __Jun 13, 2016__ 38 | 39 | * Initial release 40 | -------------------------------------------------------------------------------- /src/static/js/manage.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | $('.progress-bar').css('width', '100%'); 5 | 6 | // POST key form 7 | $('#addKey form').submit(async e => { 8 | e.preventDefault(); 9 | $('#addKey .alert').addClass('hidden'); 10 | $('#addKey .progress').removeClass('hidden'); 11 | const publicKeyArmored = $('#addKey textarea').val(); 12 | try { 13 | const response = await fetch('/api/v1/key', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({publicKeyArmored})}); 14 | const responseText = await response.text(); 15 | if (!response.ok) { 16 | return alert('addKey', 'danger', responseText); 17 | } 18 | alert('addKey', 'success', responseText); 19 | } catch (e) { 20 | console.log('Fetch error', e); 21 | alert('addKey', 'danger', 'Network did not respond'); 22 | } 23 | }); 24 | 25 | // DELETE key form 26 | $('#removeKey form').submit(async e => { 27 | e.preventDefault(); 28 | $('#removeKey .alert').addClass('hidden'); 29 | $('#removeKey .progress').removeClass('hidden'); 30 | const email = $('#removeKey input[type="email"]').val(); 31 | try { 32 | const response = await fetch(`/api/v1/key?email=${encodeURIComponent(email)}`, {method: 'DELETE'}); 33 | const responseText = await response.text(); 34 | if (!response.ok) { 35 | return alert('removeKey', 'danger', responseText); 36 | } 37 | alert('removeKey', 'success', responseText); 38 | } catch (e) { 39 | console.log('Fetch error', e); 40 | alert('removeKey', 'danger', 'Network did not respond'); 41 | } 42 | }); 43 | 44 | function alert(region, outcome, text) { 45 | $(`#${region} .progress`).addClass('hidden'); 46 | $(`#${region} .alert-${outcome} span`).text(text); 47 | $(`#${region} .alert-${outcome}`).removeClass('hidden'); 48 | } 49 | -------------------------------------------------------------------------------- /test/fixtures/key1.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | Version: OpenPGP.js v1.4.1 3 | 4 | xsBNBFZ+SREBCADFXPrLlyXIFHmBcRFEs2nwzID8X3+YmUOvY55+TJbyeggt 5 | Ccgd21JiBNuL3yilZ2HmQbjzmJvuLFIKCBSmGeg3LU3QhcoSdHZu+NtiimuJ 6 | /CruUFD0JJJn0LdV+4R9nlsoUaNtmTfmgS0ErfcICTmzASgjIwk1bKwFN6qh 7 | L+LAVNOU7QL5Jk/6MDUVIU/6sijqGAANAy3aDnFx0x0e0A0QUVdXZU4TUV42 8 | wu31rN39ZhgfHwvSEDZsljh2UvoYgS9uFfmO2CxXhoSsp3LKbwP9Os9atlOr 9 | 7vNOO+nL6GXuBFqNNNycpajiIMBLm6XTeY4ci+EaUX54q7DFUJfamBS3ABEB 10 | AAHNM3NhZmV3aXRobWUgdGVzdHVzZXIgPHNhZmV3aXRobWUudGVzdHVzZXJA 11 | Z21haWwuY29tPsLAcgQQAQgAJgUCVn5JEgYLCQgHAwIJENvAs9krG4bpBBUI 12 | AgoDFgIBAhsDAh4BAAAr6Qf/WWCYCBL2Izau5S+H5zZtCk5Rde51pGw1fsMd 13 | gMlZK7knMUSlfjIEx6C/y5uQRRJc6f44p7B609mCGwW+f91wpAGq4d6O3+BV 14 | 25GDj25UvN8dBW42cufLG7tTSXkDXQNhaWEF15yD7aDOdaWy6OpD66xiR2Rs 15 | vT9BG5la4vIVwQxcMeTg62axTe/uu7IwcxQr1zT9nNvw5lmrF68YqTVl4ArM 16 | axV9yMsbTmjYvS5LmD2vSzRi/OJzfIMYAiTbkSgoF91lFp0rAgq485MEOBjF 17 | T4CnwCVHT8BOjeBi7JnKDb3JN7HGYHX1ZwuaiYJDtkbqHKrTLSmtJJUTJBRs 18 | p14uqM7ATQRWfkkRAQgA2l9OZ2doMLKNhi6JC1Qd1iBWrmMAflbuOstoRz76 19 | C/++VUlVeT7tuOiVJtYgxc1qRgIZEwzZIpM5/p25lX6mrXkgUJd7w8EYbTqa 20 | J9h5jeontZaGHciVRAWyUy35PMevXTt4pKXQvzGS3jXK46ICE2/rxa02sE1N 21 | S1kHCMQnWh89uMpE7sIG2s8QPOYVBHk88hf4M6jGDT2f2pKFwWuJ51z9IZul 22 | wF/my6/rSBkXqhckJCOaq4H1F6iQCV6R0NmEMMe+UYz784mcw9B5JDqYoTux 23 | 3uCPEUeb7kW3M6IdPo2OSEEuvukdQvDmt3jKjnCk5RPKYZYFl9yqFVAf0gbp 24 | EwARAQABwsBfBBgBCAATBQJWfkkTCRDbwLPZKxuG6QIbDAAAJyQH/icIJUhb 25 | AuFntIB/nuX52kubnaXLlQc/erIg4y2aN92+g9ULv2myw6lf+kt5IHQtroR1 26 | MVFSZgHVwSIhrwZqbaZTvi7VZq5NYDjRL1mda+rodhyNQEVM+8Q4XZh7yR8h 27 | TNZn6OsENP1ctxs4J4T/jJL0mdhG/aCkbO4DICAEToViWOmUOpQBJwUI41Wh 28 | qSeCLRV510QasWZVe0o86yCB11gxrg/+xd5XN6Za/pTtz+4KeD3m8ssygdNS 29 | woY7ieO647qE7GagQdWP+4BIYPeEqnRTqyTMxpSivlal2IcEw/Fi0xM97+ER 30 | FtBVBq+eZC88+gSiwcmxsB8s3rMPQhJ6Q0Y= 31 | =/POi 32 | -----END PGP PUBLIC KEY BLOCK----- -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mailvelope-keyserver", 3 | "version": "4.1.0", 4 | "license": "AGPL-3.0", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/mailvelope/keyserver.git" 8 | }, 9 | "engines": { 10 | "node": ">=20", 11 | "npm": ">=10" 12 | }, 13 | "scripts": { 14 | "start": "node src", 15 | "test": "npm run test:lint && npm run test:unit && npm run test:integration", 16 | "test:lint": "eslint --ignore-pattern \"**/*.min.js\" config src test", 17 | "test:unit": "mocha --require ./test/setup.mjs --recursive ./test/unit", 18 | "test:purify": "mocha --require ./test/setup.mjs --recursive ./test/unit/purify-key-test.js", 19 | "test:public": "mocha --require ./test/setup.mjs --recursive ./test/integration/public-key-test.js", 20 | "test:integration": "mocha --exit --require ./test/setup.mjs --recursive ./test/integration", 21 | "release": "npm run release:install && npm run release:archive", 22 | "release:install": "rm -rf node_modules/ && npm ci --production", 23 | "release:archive": "zip -rq release.zip package.json package-lock.json node_modules/ *.js src/ config/ locales/", 24 | "clean": "node src/tools/clean" 25 | }, 26 | "dependencies": { 27 | "@hapi/boom": "^10.0.1", 28 | "@hapi/hapi": "^21.3.10", 29 | "@hapi/inert": "^7.1.0", 30 | "@hapi/vision": "^7.0.3", 31 | "dotenv": "^16.4.5", 32 | "ejs": "^3.1.10", 33 | "hapi-i18n": "^3.0.1", 34 | "mongodb": "^6.9.0", 35 | "nodemailer": "^6.9.15", 36 | "openpgp": "^5.11.2", 37 | "winston": "^3.14.2", 38 | "winston-syslog": "^2.7.1" 39 | }, 40 | "devDependencies": { 41 | "@eslint/js": "^9.11.1", 42 | "bootstrap": "^3.4.1", 43 | "chai": "^5.1.1", 44 | "chai-as-promised": "^8.0.0", 45 | "eslint": "^9.11.1", 46 | "globals": "^15.9.0", 47 | "jquery": "^3.7.1", 48 | "mocha": "^10.7.3", 49 | "sinon": "^19.0.2", 50 | "supertest": "^7.0.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /config/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('dotenv').config(); 4 | const util = require('../src/lib/util'); 5 | 6 | module.exports = { 7 | 8 | log: { 9 | level: process.env.LOG_LEVEL || 'info' 10 | }, 11 | 12 | syslog: { 13 | host: process.env.SYSLOG_HOST, 14 | port: util.parseNumber(process.env.SYSLOG_PORT) 15 | }, 16 | 17 | server: { 18 | port: util.parseNumber(process.env.PORT) ?? 8888, 19 | host: process.env.SERVER_HOST || 'localhost', 20 | cors: util.isTrue(process.env.CORS_HEADER), 21 | security: util.isTrue(process.env.HTTP_SECURITY_HEADER), 22 | csp: util.isTrue(process.env.CSP_HEADER) 23 | }, 24 | 25 | mongo: { 26 | uri: process.env.MONGO_URI, 27 | user: process.env.MONGO_USER, 28 | pass: process.env.MONGO_PASS 29 | }, 30 | 31 | email: { 32 | host: process.env.SMTP_HOST, 33 | port: util.parseNumber(process.env.SMTP_PORT), 34 | tls: util.isTrue(process.env.SMTP_TLS ?? true), 35 | starttls: util.isTrue(process.env.SMTP_STARTTLS ?? true), 36 | pgp: util.isTrue(process.env.SMTP_PGP ?? true), 37 | auth: { 38 | user: process.env.SMTP_USER, 39 | pass: process.env.SMTP_PASS 40 | }, 41 | sender: { 42 | name: process.env.SENDER_NAME, 43 | email: process.env.SENDER_EMAIL 44 | } 45 | }, 46 | 47 | publicKey: { 48 | purgeTimeInDays: util.parseNumber(process.env.PUBLIC_KEY_PURGE_TIME) ?? 14, 49 | uploadRateLimit: util.parseNumber(process.env.UPLOAD_RATE_LIMIT) 50 | }, 51 | 52 | purify: { 53 | purifyKey: util.isTrue(process.env.PURIFY_KEY ?? true), 54 | maxNumUserEmail: util.parseNumber(process.env.MAX_NUM_USER_EMAIL) ?? 20, 55 | maxNumSubkey: util.parseNumber(process.env.MAX_NUM_SUBKEY) ?? 20, 56 | maxNumCert: util.parseNumber(process.env.MAX_NUM_CERT) ?? 5, 57 | maxSizeUserID: util.parseNumber(process.env.MAX_SIZE_USERID) ?? 1024, 58 | maxSizePacket: util.parseNumber(process.env.MAX_SIZE_PACKET) ?? 8383, 59 | maxSizeKey: util.parseNumber(process.env.MAX_SIZE_KEY) ?? 32768 60 | } 61 | 62 | }; 63 | -------------------------------------------------------------------------------- /src/static/css/jumbotron-narrow.css: -------------------------------------------------------------------------------- 1 | /* Space out content a bit */ 2 | body { 3 | padding-top: 20px; 4 | padding-bottom: 20px; 5 | } 6 | 7 | /* Everything but the jumbotron gets side spacing for mobile first views */ 8 | .header, 9 | .marketing, 10 | .footer { 11 | padding-right: 15px; 12 | padding-left: 15px; 13 | } 14 | 15 | /* Custom page header */ 16 | .header { 17 | padding-bottom: 20px; 18 | border-bottom: 1px solid #e5e5e5; 19 | } 20 | /* Make the masthead heading the same height as the navigation */ 21 | .header h3 { 22 | margin-top: 0; 23 | margin-bottom: 0; 24 | line-height: 40px; 25 | } 26 | 27 | /* Custom page footer */ 28 | .footer { 29 | padding-top: 19px; 30 | color: #777; 31 | border-top: 1px solid #e5e5e5; 32 | } 33 | .footer nav { 34 | display: block; 35 | float: right; 36 | } 37 | .footer ul { 38 | display: block; 39 | padding-left: 10px; 40 | } 41 | .footer li { 42 | display: inline; 43 | margin: 0 8px; 44 | } 45 | .footer a { 46 | color: inherit; 47 | } 48 | 49 | /* Customize container */ 50 | @media (min-width: 768px) { 51 | .container { 52 | max-width: 730px; 53 | } 54 | } 55 | .container-narrow > hr { 56 | margin: 30px 0; 57 | } 58 | 59 | /* Main marketing message and sign up button */ 60 | .jumbotron { 61 | text-align: center; 62 | border-bottom: 1px solid #e5e5e5; 63 | } 64 | .jumbotron .btn { 65 | padding: 14px 24px; 66 | font-size: 21px; 67 | } 68 | 69 | /* Supporting marketing content */ 70 | .marketing { 71 | margin: 40px 0; 72 | } 73 | .marketing p + h4 { 74 | margin-top: 28px; 75 | } 76 | .marketing .col-lg-6{ 77 | margin-bottom: 28px; 78 | } 79 | 80 | /* Responsive: Portrait tablets and up */ 81 | @media screen and (min-width: 768px) { 82 | /* Remove the padding we set earlier */ 83 | .header, 84 | .marketing, 85 | .footer { 86 | padding-right: 0; 87 | padding-left: 0; 88 | } 89 | /* Space out the masthead */ 90 | .header { 91 | margin-bottom: 30px; 92 | } 93 | /* Remove the bottom border on the jumbotron for visual effect */ 94 | .jumbotron { 95 | border-bottom: 0; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /test/integration/email-test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('../../config/config'); 4 | const Email = require('../../src/modules/email'); 5 | const tpl = require('../../src/lib/templates'); 6 | 7 | describe('Email Integration Tests', function() { 8 | this.timeout(20000); 9 | 10 | let email; 11 | let keyId; 12 | let userId; 13 | let origin; 14 | let publicKeyArmored; 15 | 16 | const recipient = {name: 'Mailvelope Demo', email: 'demo@mailvelope.com'}; 17 | const i18n = { 18 | __: key => key, 19 | __mf: key => key 20 | }; 21 | 22 | before(() => { 23 | publicKeyArmored = require('fs').readFileSync(`${__dirname}/../fixtures/key2.asc`, 'utf8'); 24 | origin = { 25 | protocol: 'http', 26 | host: `localhost:${config.server.port}` 27 | }; 28 | email = new Email(); 29 | email.init(config.email); 30 | }); 31 | 32 | beforeEach(() => { 33 | keyId = '0123456789ABCDF0'; 34 | userId = { 35 | name: recipient.name, 36 | email: recipient.email, 37 | nonce: 'qwertzuioasdfghjkqwertzuio', 38 | publicKeyArmored 39 | }; 40 | }); 41 | 42 | describe('_sendHelper', () => { 43 | it('should work', async () => { 44 | const mailOptions = { 45 | from: {name: email._sender.name, address: email._sender.email}, 46 | to: {name: recipient.name, address: recipient.email}, 47 | subject: 'Hello ✔', // Subject line 48 | text: 'Hello world 🐴', // plaintext body 49 | html: 'Hello world 🐴' // html body 50 | }; 51 | const info = await email._sendHelper(mailOptions); 52 | expect(info).to.exist; 53 | }); 54 | }); 55 | 56 | describe('send verifyKey template', () => { 57 | it('should send plaintext email', async () => { 58 | await email.send({template: tpl.verifyKey, userId, keyId, origin, i18n}); 59 | }); 60 | 61 | it('should send pgp encrypted email', async () => { 62 | await email.send({template: tpl.verifyKey, userId, keyId, publicKeyArmored: userId.publicKeyArmored, origin, i18n}); 63 | }); 64 | }); 65 | 66 | describe('send verifyRemove template', () => { 67 | it('should send plaintext email', async () => { 68 | await email.send({template: tpl.verifyRemove, userId, keyId, origin, i18n}); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /test/fixtures/key5.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | Version: GnuPG v2.0.22 (GNU/Linux) 3 | 4 | mI0EUmEvTgEEANyWtQQMOybQ9JltDqmaX0WnNPJeLILIM36sw6zL0nfTQ5zXSS3+ 5 | fIF6P29lJFxpblWk02PSID5zX/DYU9/zjM2xPO8Oa4xo0cVTOTLj++Ri5mtr//f5 6 | GLsIXxFrBJhD/ghFsL3Op0GXOeLJ9A5bsOn8th7x6JucNKuaRB6bQbSPABEBAAG0 7 | JFRlc3QgTWNUZXN0aW5ndG9uIDx0ZXN0QGV4YW1wbGUuY29tPoi5BBMBAgAjBQJS 8 | YS9OAhsvBwsJCAcDAgEGFQgCCQoLBBYCAwECHgECF4AACgkQSmNhOk1uQJQwDAP6 9 | AgrTyqkRlJVqz2pb46TfbDM2TDF7o9CBnBzIGoxBhlRwpqALz7z2kxBDmwpQa+ki 10 | Bq3jZN/UosY9y8bhwMAlnrDY9jP1gdCo+H0sD48CdXybblNwaYpwqC8VSpDdTndf 11 | 9j2wE/weihGp/DAdy/2kyBCaiOY1sjhUfJ1GogF49rDRwc7BzAEQAAEBAAAAAAAA 12 | AAAAAAAA/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQN 13 | DAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/ 14 | 2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIy 15 | MjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAFABQDASIAAhEBAxEB/8QAHwAAAQUB 16 | AQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQID 17 | AAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0 18 | NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKT 19 | lJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl 20 | 5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL 21 | /8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHB 22 | CSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpj 23 | ZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3 24 | uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIR 25 | AxEAPwD3+iiigAooooA//9mIuQQTAQIAIwUCUzxDqQIbLwcLCQgHAwIBBhUIAgkK 26 | CwQWAgMBAh4BAheAAAoJEEpjYTpNbkCU9PEEAKMMaXjhGdgDISBXAAEVXL6MB3x1 27 | d/7zBdnUljh1gM34TSKvbeZf7h/1DNgLbJFfSF3KiLViiqRVOumIkjwNIMZPqYtu 28 | WoEcElY50mvTETzOKemCt1GYI0GhOY2uZOVRtQLrkX0CB9r5hEQalkrnjNKlbghj 29 | LfOYu1uARF16cZUWuI0EUmEvTgEEAOkfz7QRWiWk+I6tdMqgEpOLKsFTLHOh3Inz 30 | OZUnccxMRT++J2lDDMhLChz+d0MUxdBq6rrGoEIP2bYE9AjdR1DNedsuwAjnadYI 31 | io6TMzk0ApagqHJcr1jhQfi/0sBhCCX+y0ghK8KAbiYnyXPMQFa9F19CbYaFvrj/ 32 | dXk0N16bABEBAAGJAT0EGAECAAkFAlJhL04CGy4AqAkQSmNhOk1uQJSdIAQZAQIA 33 | BgUCUmEvTgAKCRDghPdEbCAsl7qiBADZpokQgEhe2Cuz7xZIniTcM3itFdxdpRl/ 34 | rrumN0P2cXbcHOMUfpnvwkgZrFEcl0ztvTloTxi7Mzx/c0iVPQXQ4ur9Mjaa5hT1 35 | /9TYNAG5/7ApMHrb48QtWCL0yxcLVC/+7+jUtm2abFMUU4PfnEqzFlkjY4mPalCm 36 | o5tbbszw2VwFBADDZgDd8Vzfyo8r49jitnJNF1u+PLJf7XN6oijzCftAJDBez44Z 37 | ofZ8ahPfkAhJe6opxaqgS47s4FIQVOEJcF9RgwLTU6uooSzA+b9XfNmQu7TWrXZQ 38 | zBlpyHbxDAr9hmXLiKg0Pa11rOPXu7atTZ3C2Ic97WIyoaBUyhCKt8tz6Q== 39 | =MVfN 40 | -----END PGP PUBLIC KEY BLOCK----- 41 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2020 Mailvelope GmbH 3 | * Licensed under the GNU Affero General Public License version 3 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const Hapi = require('@hapi/hapi'); 9 | const Vision = require('@hapi/vision'); 10 | const Inert = require('@hapi/inert'); 11 | const ejs = require('ejs'); 12 | const config = require('../config/config'); 13 | const path = require('path'); 14 | const i18n = require('hapi-i18n'); 15 | 16 | const Mongo = require('./modules/mongo'); 17 | const Email = require('./modules/email'); 18 | const PurifyKey = require('./modules/purify-key'); 19 | const PGP = require('./modules/pgp'); 20 | const PublicKey = require('./modules/public-key'); 21 | 22 | const HKP = require('./route/hkp'); 23 | const REST = require('./route/rest'); 24 | const WWW = require('./route/www'); 25 | const CSP = require('./lib/csp'); 26 | 27 | const init = async (conf = config) => { 28 | const server = Hapi.server({ 29 | port: conf.server.port, 30 | host: conf.server.host, 31 | }); 32 | // modules 33 | const mongo = new Mongo(); 34 | await mongo.init(conf.mongo); 35 | const email = new Email(); 36 | email.init(conf.email); 37 | const purify = new PurifyKey(conf.purify); 38 | const pgp = new PGP(purify); 39 | const publicKey = new PublicKey(pgp, mongo, email); 40 | await publicKey.init(); 41 | server.app.publicKey = publicKey; 42 | // views 43 | await server.register(Vision); 44 | server.views({ 45 | engines: { 46 | html: ejs 47 | }, 48 | path: path.join(__dirname, 'view'), 49 | layout: true 50 | }); 51 | // static 52 | await server.register(Inert); 53 | server.route({ 54 | method: 'GET', 55 | path: '/{param*}', 56 | handler: { 57 | directory: { 58 | path: path.join(__dirname, 'static') 59 | } 60 | } 61 | }); 62 | // content security policy 63 | if (conf.server.csp) { 64 | await server.register({plugin: CSP.plugin}); 65 | } 66 | // routes 67 | await server.register({plugin: HKP.plugin, options: conf}); 68 | await server.register({plugin: REST.plugin, options: conf}); 69 | await server.register({plugin: WWW.plugin, options: conf}); 70 | // translation 71 | await server.register({ 72 | plugin: i18n, 73 | options: { 74 | locales: ['de', 'en'], 75 | directory: path.join(__dirname, '../locales'), 76 | languageHeaderField: 'Accept-Language', 77 | defaultLocale: 'en' 78 | } 79 | }); 80 | // start 81 | await server.start(); 82 | return server; 83 | }; 84 | 85 | module.exports = init; 86 | -------------------------------------------------------------------------------- /src/view/index.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 11 |

Mailvelope Key Server

12 |
13 | 14 |
15 |

Secure. Easy.

16 |

The Mailvelope OpenPGP public key server is the first of its kind. It allows automatic public key lookup to make email privacy just as painless as modern messengers.

17 |

Try it now

18 |
19 | 20 |
21 |
22 |

Privacy made Easy

23 |

Automatic key lookup in OpenPGP mail user agents makes reading and writing encrypted email just as painless as modern messenengers.

24 | 25 |

No Web of Trust

26 |

No more key signing parties or publishing your social network online. You can even delete your public key at anytime. Learn more

27 | 28 |

Secure REST API

29 |

The server offers a modern REST API over HTTPS with HSTS and public key pinning that can be integrated into any app architecture. Learn more

30 |
31 | 32 |
33 |

Verify Yourself

34 |

The server verifies email address ownership as well as private key ownership by sending an encrypted verification email.

35 | 36 |

Completely Open

37 |

The code is licensed under the AGPL v3.0 which means you are free to host your own key directory under your domain. Learn more

38 | 39 |

HKP Compatible

40 |

No need to update your current OpenPGP plugin. Just copy and paste hkps://keys.mailvelope.com into your settings and go. Learn more

41 |
42 |
43 | 44 | <%- include('footer.html') %> 45 | 46 |
47 | -------------------------------------------------------------------------------- /src/tools/clean.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2024 Mailvelope GmbH 3 | * Licensed under the GNU Affero General Public License version 3 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const config = require('../../config/config'); 9 | const Mongo = require('../modules/mongo'); 10 | const PurifyKey = require('../modules/purify-key'); 11 | const PGP = require('../modules/pgp'); 12 | 13 | const DB_TYPE = 'publickey'; 14 | const KEY_SIZE = 1; // divided by 4/3 gives binary size of key 15 | const MAX_UPLOAD_DATE = new Date(new Date().setDate(new Date().getDate() - config.publicKey.purgeTimeInDays)); // now - purgeTimeInDays 16 | const YEAR = parseInt(process.argv[2] ?? MAX_UPLOAD_DATE.getFullYear()); 17 | 18 | let mongo; 19 | let pgp; 20 | 21 | async function init() { 22 | mongo = new Mongo(); 23 | await mongo.init(config.mongo); 24 | const purify = new PurifyKey({...config.purify, maxNumUserEmail: 60, maxNumSubkey: 25, maxSizeKey: 64 * 1024}); 25 | pgp = new PGP(purify); 26 | } 27 | 28 | function aggregate() { 29 | return mongo.aggregate([ 30 | {$match: {uploaded: {$gte: new Date(YEAR, 0, 1), $lt: new Date(YEAR + 1, 0, 1)}}}, 31 | {$match: {uploaded: {$lt: MAX_UPLOAD_DATE}}}, 32 | {$project: {keySize: {$binarySize: '$publicKeyArmored'}}}, 33 | {$match: {keySize: {$gt: KEY_SIZE}}} 34 | ], DB_TYPE); 35 | } 36 | 37 | async function clean() { 38 | try { 39 | console.log(`Start cleaning year ${YEAR}...`); 40 | await init(); 41 | const result = await aggregate(); 42 | let count = 0; 43 | for await (const document of result) { 44 | await cleanKey(document); 45 | count++; 46 | } 47 | console.log('Number of keys processed:', count); 48 | } catch (e) { 49 | console.log('Error while traversing keys:', e); 50 | } finally { 51 | await mongo.disconnect(); 52 | } 53 | } 54 | 55 | async function cleanKey({_id}) { 56 | const key = await mongo.get({_id}, DB_TYPE); 57 | if (!key.publicKeyArmored) { 58 | console.log('No armored key. Key is not yet verified. Skip'); 59 | return; 60 | } 61 | try { 62 | const purified = await pgp.parseKey(key.publicKeyArmored); 63 | // filter out all unverified user ID and those that are not in the purified set 64 | key.userIds = key.userIds.filter(userId => userId.verified && purified.userIds.some(id => id.email === userId.email)); 65 | if (!key.userIds.length) { 66 | throw new Error('No user ID after comparing with purified key.'); 67 | } 68 | const publicKeyArmored = await pgp.filterKeyByUserIds(key.userIds, purified.publicKeyArmored); 69 | key.publicKeyArmored = publicKeyArmored; 70 | await mongo.replace({_id}, key, DB_TYPE); 71 | } catch (e) { 72 | console.log('Parsing of key failed:', e.message); 73 | await mongo.remove({_id}, DB_TYPE); 74 | console.log(`Key ${key.fingerprint} removed.`); 75 | } 76 | } 77 | 78 | clean(); 79 | -------------------------------------------------------------------------------- /test/unit/email-test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const log = require('../../src/lib/log'); 4 | const Email = require('../../src/modules/email'); 5 | const nodemailer = require('nodemailer'); 6 | 7 | describe('Email Unit Tests', () => { 8 | const sandbox = sinon.createSandbox(); 9 | let email; 10 | let sendFnStub; 11 | 12 | const template = () => ({ 13 | subject: 'foo', 14 | text: 'bar', 15 | html: 'bar' 16 | }); 17 | const sender = { 18 | name: 'Foo Bar', 19 | email: 'foo@bar.com' 20 | }; 21 | const userId1 = { 22 | name: 'name1', 23 | email: 'email1', 24 | nonce: 'qwertzuioasdfghjkqwertzuio' 25 | }; 26 | const keyId = '0123456789ABCDF0'; 27 | const origin = { 28 | protocol: 'http', 29 | host: 'localhost:8888' 30 | }; 31 | const mailOptions = { 32 | from: sender, 33 | to: sender, 34 | subject: 'Hello ✔', // Subject line 35 | text: 'Hello world 🐴', // plaintext body 36 | html: 'Hello world 🐴' // html body 37 | }; 38 | 39 | beforeEach(() => { 40 | sendFnStub = sandbox.stub(); 41 | sandbox.stub(nodemailer, 'createTransport').returns({ 42 | sendMail: sendFnStub 43 | }); 44 | 45 | sandbox.stub(log); 46 | 47 | email = new Email(nodemailer); 48 | email.init({ 49 | host: 'host', 50 | auth: {user: 'user', pass: 'pass'}, 51 | sender 52 | }); 53 | expect(email._sender).to.equal(sender); 54 | }); 55 | 56 | afterEach(() => { 57 | sandbox.restore(); 58 | }); 59 | 60 | describe('send', () => { 61 | beforeEach(() => { 62 | sandbox.stub(email, '_sendHelper').returns(Promise.resolve({response: '250'})); 63 | }); 64 | 65 | it('should work', async () => { 66 | const info = await email.send({template, userId: userId1, keyId, origin}); 67 | 68 | expect(info.response).to.match(/^250/); 69 | }); 70 | }); 71 | 72 | describe('_sendHelper', () => { 73 | it('should work', async () => { 74 | sendFnStub.returns(Promise.resolve({response: '250'})); 75 | 76 | const info = await email._sendHelper(mailOptions); 77 | 78 | expect(info.response).to.match(/^250/); 79 | }); 80 | 81 | it('should log warning for reponse error', async () => { 82 | sendFnStub.returns(Promise.resolve({response: '554'})); 83 | 84 | const info = await email._sendHelper(mailOptions); 85 | 86 | expect(info.response).to.match(/^554/); 87 | expect(log.warning.calledOnce).to.be.true; 88 | }); 89 | 90 | it('should fail', async () => { 91 | sendFnStub.returns(Promise.reject(new Error('boom'))); 92 | 93 | try { 94 | await email._sendHelper(mailOptions); 95 | } catch (e) { 96 | expect(log.error.calledOnce).to.be.true; 97 | expect(e.output.statusCode).to.equal(500); 98 | expect(e.message).to.match(/failed/); 99 | } 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /test/integration/mongo-test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('../../config/config'); 4 | const log = require('../../src/lib/log'); 5 | const Mongo = require('../../src/modules/mongo'); 6 | 7 | describe('Mongo Integration Tests', function() { 8 | this.timeout(20000); 9 | 10 | const DB_TYPE = 'apple'; 11 | const sandbox = sinon.createSandbox(); 12 | const conf = structuredClone(config); 13 | let mongo; 14 | 15 | before(async () => { 16 | sandbox.stub(log); 17 | mongo = new Mongo(); 18 | conf.mongo.uri = `${config.mongo.uri}-int`; 19 | await mongo.init(conf.mongo); 20 | }); 21 | 22 | beforeEach(async () => { 23 | await mongo.clear(DB_TYPE); 24 | }); 25 | 26 | after(async () => { 27 | sandbox.restore(); 28 | await mongo.clear(DB_TYPE); 29 | await mongo.disconnect(); 30 | }); 31 | 32 | describe('create', () => { 33 | it('should insert a document', async () => { 34 | const r = await mongo.create({_id: '0'}, DB_TYPE); 35 | expect(r.acknowledged).to.be.true; 36 | }); 37 | 38 | it('should fail if two with the same ID are inserted', async () => { 39 | let r = await mongo.create({_id: '0'}, DB_TYPE); 40 | expect(r.acknowledged).to.be.true; 41 | try { 42 | r = await mongo.create({_id: '0'}, DB_TYPE); 43 | expect(true).to.be.false; 44 | } catch (e) { 45 | expect(e.message).to.match(/duplicate/); 46 | } 47 | }); 48 | }); 49 | 50 | describe('batch', () => { 51 | it('should insert a document', async () => { 52 | const r = await mongo.batch([{_id: '0'}, {_id: '1'}], DB_TYPE); 53 | expect(r.insertedCount).to.equal(2); 54 | }); 55 | 56 | it('should fail if docs with the same ID are inserted', async () => { 57 | let r = await mongo.batch([{_id: '0'}, {_id: '1'}], DB_TYPE); 58 | expect(r.insertedCount).to.equal(2); 59 | try { 60 | r = await mongo.batch([{_id: '0'}, {_id: '1'}], DB_TYPE); 61 | expect(true).to.be.false; 62 | } catch (e) { 63 | expect(e.message).to.match(/duplicate/); 64 | } 65 | }); 66 | }); 67 | 68 | describe('update', () => { 69 | it('should update a document', async () => { 70 | let r = await mongo.create({_id: '0'}, DB_TYPE); 71 | r = await mongo.update({_id: '0'}, {foo: 'bar'}, DB_TYPE); 72 | expect(r.modifiedCount).to.equal(1); 73 | r = await mongo.get({_id: '0'}, DB_TYPE); 74 | expect(r.foo).to.equal('bar'); 75 | }); 76 | }); 77 | 78 | describe('get', () => { 79 | it('should get a document', async () => { 80 | let r = await mongo.create({_id: '0'}, DB_TYPE); 81 | r = await mongo.get({_id: '0'}, DB_TYPE); 82 | expect(r).to.exist; 83 | }); 84 | }); 85 | 86 | describe('list', () => { 87 | it('should list documents', async () => { 88 | let r = await mongo.batch([{_id: '0', foo: 'bar'}, {_id: '1', foo: 'bar'}], DB_TYPE); 89 | r = await mongo.list({foo: 'bar'}, DB_TYPE); 90 | expect(r).to.deep.equal([{_id: '0', foo: 'bar'}, {_id: '1', foo: 'bar'}], DB_TYPE); 91 | }); 92 | }); 93 | 94 | describe('remove', () => { 95 | it('should remove a document', async () => { 96 | let r = await mongo.create({_id: '0'}, DB_TYPE); 97 | r = await mongo.remove({_id: '0'}, DB_TYPE); 98 | r = await mongo.get({_id: '0'}, DB_TYPE); 99 | expect(r).to.not.exist; 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /src/view/manage.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 11 |

Mailvelope Key Server

12 |
13 | 14 |
15 |
16 |

OpenPGP key upload

17 | 20 | 23 | 26 |
27 |

28 | 29 |
30 |
31 |
32 | 33 |
34 |

OpenPGP key lookup

35 |
36 | 37 |
38 | 39 | 40 | 41 | 42 |
43 |
44 |
45 |
46 | 47 |
48 |

OpenPGP key removal

49 | 52 | 55 | 58 |
59 |
60 | 61 | 62 | 63 | 64 |
65 |
66 |
67 |
68 | 69 |
70 |

HKP and REST Apis

71 |

The server offers a modern REST api over HTTPS with HSTS and public key pinning that can be integrated into any app architecture. It is also compatible to the OpenPGP HTTP Keyserver Protocol (HKP). Just copy and paste hkps://keys.mailvelope.com into your current OpenPGP plugin and go. Learn more.

72 |
73 |
74 | 75 | <%- include('footer.html') %> 76 | 77 |
78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import js from "@eslint/js"; 3 | 4 | export default [js.configs.recommended, { 5 | files: ["**/*.js"], 6 | languageOptions: { 7 | globals: { 8 | ...globals.node, 9 | ...globals.browser, 10 | ...globals.jquery, 11 | }, 12 | ecmaVersion: 2022, 13 | sourceType: "commonjs", 14 | }, 15 | rules: { 16 | strict: ["error", "global"], 17 | "no-console": 0, 18 | "no-empty": ["error", { 19 | allowEmptyCatch: true, 20 | }], 21 | "require-atomic-updates": 0, 22 | curly: 2, 23 | "no-return-await": 2, 24 | "no-eval": 2, 25 | "no-extend-native": 2, 26 | "no-global-assign": 2, 27 | "no-implicit-coercion": 2, 28 | "no-implicit-globals": 2, 29 | "no-implied-eval": 2, 30 | "no-lone-blocks": 2, 31 | "no-unused-vars": ["error", { 32 | ignoreRestSiblings: true, 33 | caughtErrors: "none" 34 | }], 35 | "no-useless-escape": 0, 36 | "array-bracket-newline": ["warn", "consistent"], 37 | "array-bracket-spacing": 1, 38 | "block-spacing": 1, 39 | 40 | "brace-style": ["warn", "1tbs", { 41 | allowSingleLine: true, 42 | }], 43 | "comma-spacing": 1, 44 | "computed-property-spacing": 1, 45 | "eol-last": 1, 46 | "func-call-spacing": 1, 47 | indent: ["warn", 2, { 48 | MemberExpression: 0, 49 | SwitchCase: 1, 50 | }], 51 | "key-spacing": ["warn", { 52 | mode: "minimum", 53 | }], 54 | "keyword-spacing": 1, 55 | "linebreak-style": 1, 56 | "lines-between-class-members": 1, 57 | "new-parens": ["warn"], 58 | "no-multiple-empty-lines": ["warn", { 59 | max: 1, 60 | }], 61 | "no-trailing-spaces": 1, 62 | "no-var": 1, 63 | "object-curly-newline": ["warn", { 64 | consistent: true, 65 | }], 66 | "object-curly-spacing": ["warn", "never"], 67 | "one-var": ["warn", "never"], 68 | "padded-blocks": ["warn", "never"], 69 | "prefer-object-spread": 1, 70 | quotes: ["warn", "single", { 71 | avoidEscape: true, 72 | }], 73 | semi: ["warn", "always"], 74 | "semi-spacing": 1, 75 | "space-before-blocks": 1, 76 | "space-before-function-paren": ["warn", { 77 | anonymous: "never", 78 | named: "never", 79 | asyncArrow: "always", 80 | }], 81 | "space-in-parens": ["warn", "never"], 82 | "space-infix-ops": 1, 83 | "arrow-body-style": ["warn", "as-needed"], 84 | "arrow-parens": ["warn", "as-needed"], 85 | "arrow-spacing": 1, 86 | "no-useless-constructor": 1, 87 | "object-shorthand": ["warn", "always", { 88 | avoidQuotes: true, 89 | }], 90 | "prefer-arrow-callback": ["warn", { 91 | allowNamedFunctions: true, 92 | }], 93 | "prefer-const": ["warn", { 94 | destructuring: "all", 95 | }], 96 | "prefer-template": 1, 97 | "template-curly-spacing": ["warn", "never"], 98 | "no-template-curly-in-string": "warn", 99 | }, 100 | }, 101 | { 102 | files: ["**/*.mjs"], 103 | languageOptions: { 104 | globals: { 105 | ...globals.node, 106 | ...globals.browser 107 | }, 108 | ecmaVersion: 2022, 109 | sourceType: "module" 110 | } 111 | }, 112 | { 113 | files: ["test/**/*.js"], 114 | languageOptions: { 115 | globals: { 116 | ...globals.mocha, 117 | expect: true, 118 | sinon: true, 119 | }, 120 | }, 121 | rules: { 122 | "no-shadow": 1, 123 | } 124 | }]; 125 | -------------------------------------------------------------------------------- /test/unit/util-test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const util = require('../../src/lib/util'); 4 | 5 | describe('Util Unit Tests', () => { 6 | describe('isString', () => { 7 | it('should be true for string', () => { 8 | expect(util.isString('asdf')).to.be.true; 9 | }); 10 | it('should be true for String object', () => { 11 | expect(util.isString(String('asdf'))).to.be.true; 12 | }); 13 | it('should be true for empty String', () => { 14 | expect(util.isString('')).to.be.true; 15 | }); 16 | it('should be false for undefined', () => { 17 | expect(util.isString(undefined)).to.be.false; 18 | }); 19 | it('should be false for Object', () => { 20 | expect(util.isString({})).to.be.false; 21 | }); 22 | }); 23 | 24 | describe('isTrue', () => { 25 | it('should be true for "true"', () => { 26 | expect(util.isTrue('true')).to.be.true; 27 | }); 28 | it('should be true for true', () => { 29 | expect(util.isTrue(true)).to.be.true; 30 | }); 31 | it('should be false for "false"', () => { 32 | expect(util.isTrue('false')).to.be.false; 33 | }); 34 | it('should be false for false', () => { 35 | expect(util.isTrue(false)).to.be.false; 36 | }); 37 | it('should be true for a random string', () => { 38 | expect(util.isTrue('asdf')).to.be.false; 39 | }); 40 | it('should be true for undefined', () => { 41 | expect(util.isTrue(undefined)).to.be.false; 42 | }); 43 | it('should be true for null', () => { 44 | expect(util.isTrue(null)).to.be.false; 45 | }); 46 | }); 47 | 48 | describe('isKeyId', () => { 49 | it('should be true for 16 byte hex', () => { 50 | expect(util.isKeyId('0123456789ABCDEF')).to.be.true; 51 | }); 52 | it('should be false for 16 byte non-hex', () => { 53 | expect(util.isKeyId('0123456789ABCDEZ')).to.be.false; 54 | }); 55 | it('should be false for 15 byte hex', () => { 56 | expect(util.isKeyId('0123456789ABCDE')).to.be.false; 57 | }); 58 | it('should be false for 17 byte hex', () => { 59 | expect(util.isKeyId('0123456789ABCDEF0')).to.be.false; 60 | }); 61 | it('should be false for undefined', () => { 62 | expect(util.isKeyId(undefined)).to.be.false; 63 | }); 64 | it('should be false for Object', () => { 65 | expect(util.isKeyId({})).to.be.false; 66 | }); 67 | }); 68 | 69 | describe('isFingerPrint', () => { 70 | it('should be true for 40 byte hex', () => { 71 | expect(util.isFingerPrint('0123456789ABCDEF0123456789ABCDEF01234567')).to.be.true; 72 | }); 73 | it('should be false for 40 byte non-hex', () => { 74 | expect(util.isKeyId('0123456789ABCDEF0123456789ABCDEF0123456Z')).to.be.false; 75 | }); 76 | it('should be false for 39 byte hex', () => { 77 | expect(util.isFingerPrint('0123456789ABCDEF0123456789ABCDEF0123456')).to.be.false; 78 | }); 79 | it('should be false for 41 byte hex', () => { 80 | expect(util.isFingerPrint('0123456789ABCDEF0123456789ABCDEF012345678')).to.be.false; 81 | }); 82 | it('should be false for undefined', () => { 83 | expect(util.isFingerPrint(undefined)).to.be.false; 84 | }); 85 | it('should be false for Object', () => { 86 | expect(util.isFingerPrint({})).to.be.false; 87 | }); 88 | }); 89 | 90 | describe('isEmail', () => { 91 | it('should be true valid email', () => { 92 | expect(util.isEmail('a@b.co')).to.be.true; 93 | }); 94 | it('should be false for too short TLD', () => { 95 | expect(util.isEmail('a@b.c')).to.be.false; 96 | }); 97 | it('should be false for no .', () => { 98 | expect(util.isEmail('a@bco')).to.be.false; 99 | }); 100 | it('should be false for no @', () => { 101 | expect(util.isEmail('ab.co')).to.be.false; 102 | }); 103 | it('should be false invalid cahr', () => { 104 | expect(util.isEmail('a<@b.co')).to.be.false; 105 | }); 106 | it('should be false for undefined', () => { 107 | expect(util.isEmail(undefined)).to.be.false; 108 | }); 109 | it('should be false for Object', () => { 110 | expect(util.isEmail({})).to.be.false; 111 | }); 112 | }); 113 | 114 | describe('random', () => { 115 | it('should generate random 32 char hex string', () => { 116 | expect(util.random().length).to.equal(32); 117 | }); 118 | 119 | it('should generate random 16 char hex string', () => { 120 | expect(util.random(8).length).to.equal(16); 121 | }); 122 | }); 123 | 124 | describe('origin', () => { 125 | it('should work', () => { 126 | expect(util.origin({info: {host: 'h'}, server: {info: {protocol: 'https'}}, headers: {}})).to.exist; 127 | }); 128 | }); 129 | 130 | describe('url', () => { 131 | it('should work with resource', () => { 132 | const url = util.url({host: 'localhost', protocol: 'http'}, '/foo'); 133 | expect(url).to.equal('http://localhost/foo'); 134 | }); 135 | 136 | it('should work without resource', () => { 137 | const url = util.url({host: 'localhost', protocol: 'http'}); 138 | expect(url).to.equal('http://localhost'); 139 | }); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /src/route/rest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2020 Mailvelope GmbH 3 | * Licensed under the GNU Affero General Public License version 3 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const Boom = require('@hapi/boom'); 9 | const util = require('../lib/util'); 10 | 11 | /** 12 | * The REST api to provide additional functionality on top of HKP 13 | */ 14 | class REST { 15 | /** 16 | * Create an instance of the REST server 17 | * @param {Object} publicKey An instance of the public key service 18 | * @param {Object} userId An instance of the user id service 19 | */ 20 | constructor(publicKey) { 21 | this._publicKey = publicKey; 22 | } 23 | 24 | /** 25 | * Public key / user ID upload via http POST 26 | * @param {Object} request - hapi request object 27 | * @param {Object} h - hapi response toolkit 28 | */ 29 | async create(request, h) { 30 | const {emails, publicKeyArmored} = request.payload; 31 | if (!publicKeyArmored) { 32 | return Boom.badRequest('No public armored key found'); 33 | } 34 | const origin = util.origin(request); 35 | await this._publicKey.put({emails, publicKeyArmored, origin, i18n: request.i18n}); 36 | return h.response('Upload successful. Check your inbox to verify your email address.').code(200); 37 | } 38 | 39 | /** 40 | * Public key query via http GET 41 | * @param {Object} request - hapi request object 42 | * @param {Object} h - hapi response toolkit 43 | */ 44 | async query(request, h) { 45 | const {op} = request.query; 46 | if (op === 'verify') { 47 | return this.verify(request, h); 48 | } else if (op === 'verifyRemove') { 49 | return this.verifyRemove(request, h); 50 | } 51 | // do READ if no 'op' provided 52 | const {keyId, fingerprint, email} = request.query; 53 | if (!keyId && !fingerprint && ! email || 54 | keyId && !util.isKeyId(keyId) || fingerprint && !util.isFingerPrint(fingerprint) || email && !util.isEmail(email)) { 55 | return Boom.badRequest('Missing parameter: keyId, fingerprint or email.'); 56 | } 57 | return h.response(await this._publicKey.get({keyId, fingerprint, email, i18n: request.i18n})); 58 | } 59 | 60 | /** 61 | * Verify a public key's user id via http GET 62 | * @param {Object} request - hapi request object 63 | * @param {Object} h - hapi response toolkit 64 | */ 65 | async verify(request, h) { 66 | const {keyId, nonce} = request.query; 67 | if (!util.isKeyId(keyId) || !util.isNonce(nonce)) { 68 | throw Boom.badRequest('Invalid parameter keyId or nonce'); 69 | } 70 | const {email} = await this._publicKey.verify({keyId, nonce}); 71 | // create link for sharing 72 | const link = util.url(util.origin(request), `/pks/lookup?op=get&search=${email}`); 73 | return h.view('verify-success', {email, link}); 74 | } 75 | 76 | /** 77 | * Request public key removal via http DELETE 78 | * @param {Object} request - hapi request object 79 | * @param {Object} h - hapi response toolkit 80 | */ 81 | async remove(request, h) { 82 | const {keyId, email} = request.query; 83 | const origin = util.origin(request); 84 | if (!util.isKeyId(keyId) && !util.isEmail(email)) { 85 | throw Boom.badRequest('Invalid parameter keyId or email'); 86 | } 87 | await this._publicKey.requestRemove({keyId, email, origin, i18n: request.i18n}); 88 | return h.response('Check your inbox to verify the removal of your email address.').code(200); 89 | } 90 | 91 | /** 92 | * Verify public key removal via http GET 93 | * @param {Object} request - hapi request object 94 | * @param {Object} h - hapi response toolkit 95 | */ 96 | async verifyRemove(request, h) { 97 | const {keyId, nonce} = request.query; 98 | if (!util.isKeyId(keyId) || !util.isNonce(nonce)) { 99 | throw Boom.badRequest('Invalid parameter keyId or nonce'); 100 | } 101 | const {email} = await this._publicKey.verifyRemove({keyId, nonce}); 102 | return h.view('removal-success', {email}); 103 | } 104 | } 105 | 106 | exports.plugin = { 107 | name: 'REST', 108 | async register(server, options) { 109 | const rest = new REST(server.app.publicKey); 110 | 111 | const routeOptions = { 112 | bind: rest, 113 | cors: options.server.cors, 114 | security: options.server.security, 115 | ext: { 116 | onPreResponse: { 117 | method({response}, h) { 118 | if (!response.isBoom) { 119 | return h.continue; 120 | } 121 | return h.response(response.message).code(response.output.statusCode).type('text/plain'); 122 | } 123 | } 124 | } 125 | }; 126 | 127 | server.route({ 128 | method: 'POST', 129 | path: '/api/v1/key', 130 | handler: rest.create, 131 | options: routeOptions 132 | }); 133 | 134 | server.route({ 135 | method: 'GET', 136 | path: '/api/v1/key', 137 | handler: rest.query, 138 | options: routeOptions 139 | }); 140 | 141 | server.route({ 142 | method: 'DELETE', 143 | path: '/api/v1/key', 144 | handler: rest.remove, 145 | options: routeOptions 146 | }); 147 | } 148 | }; 149 | -------------------------------------------------------------------------------- /src/modules/email.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2020 Mailvelope GmbH 3 | * Licensed under the GNU Affero General Public License version 3 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const Boom = require('@hapi/boom'); 9 | const log = require('../lib/log'); 10 | const util = require('../lib/util'); 11 | const openpgp = require('openpgp'); 12 | const nodemailer = require('nodemailer'); 13 | 14 | /** 15 | * A simple wrapper around Nodemailer to send verification emails 16 | */ 17 | class Email { 18 | /** 19 | * Create an instance of the reusable nodemailer SMTP transport. 20 | * @param {String} host SMTP server's hostname: 'smtp.gmail.com' 21 | * @param {Object} auth Auth credential: { user:'user@gmail.com', pass:'pass' } 22 | * @param {Object} sender message 'FROM' field: { name:'Your Support', email:'noreply@exmple.com' } 23 | * @param {String} port (optional) SMTP server's SMTP port. Defaults to 465. 24 | * @param {Boolean} tls (optional) if TSL should be used. Defaults to true. 25 | * @param {Boolean} starttls (optional) force STARTTLS to prevent downgrade attack. Defaults to true. 26 | * @param {Boolean} pgp (optional) if outgoing emails are encrypted to the user's public key. 27 | */ 28 | init({host, port = 465, auth, tls, starttls, pgp, sender}) { 29 | this._transporter = nodemailer.createTransport({ 30 | host, 31 | port, 32 | auth, 33 | secure: tls, 34 | requireTLS: starttls 35 | }); 36 | this._usePGPEncryption = util.isTrue(pgp); 37 | this._sender = sender; 38 | } 39 | 40 | /** 41 | * Send the verification email to the user using a template. 42 | * @param {Object} template the email template function to use 43 | * @param {Object} userId recipient user ID object: { name:'Jon Smith', email:'j@smith.com' } 44 | * @param {String} keyId key ID of public key 45 | * @param {String} publicKeyArmored public key of recipient 46 | * @param {Object} origin origin of the server 47 | * @return {Promise} reponse object containing SMTP info 48 | */ 49 | async send({template, userId, keyId, origin, publicKeyArmored, i18n}) { 50 | const compiled = template({ 51 | ...userId, 52 | origin, 53 | keyId, 54 | i18n 55 | }); 56 | if (this._usePGPEncryption && publicKeyArmored) { 57 | compiled.text = await this._pgpEncrypt(compiled.text, publicKeyArmored, i18n); 58 | } 59 | const sendOptions = { 60 | from: {name: this._sender.name, address: this._sender.email}, 61 | to: {name: userId.name, address: userId.email}, 62 | subject: compiled.subject, 63 | text: compiled.text 64 | }; 65 | return this._sendHelper(sendOptions); 66 | } 67 | 68 | /** 69 | * Encrypt the message body using OpenPGP.js 70 | * @param {String} plaintext the plaintext message body 71 | * @param {String} publicKeyArmored the recipient's public key 72 | * @return {Promise} the encrypted PGP message block 73 | */ 74 | async _pgpEncrypt(plaintext, publicKeyArmored, i18n) { 75 | let key; 76 | try { 77 | key = await openpgp.readKey({armoredKey: publicKeyArmored}); 78 | } catch (e) { 79 | log.error('Failed to parse PGP key in _pgpEncrypt\n%s\n%s', e, publicKeyArmored); 80 | throw Boom.badImplementation('Failed to parse PGP key'); 81 | } 82 | try { 83 | const message = await openpgp.createMessage({text: plaintext}); 84 | const ciphertext = await openpgp.encrypt({ 85 | message, 86 | encryptionKeys: key, 87 | date: util.getTomorrow(), 88 | config: {showComment: true, commentString: `*** ${i18n.__('verify_key_comment')} ***`} 89 | }); 90 | return ciphertext; 91 | } catch (error) { 92 | log.error('Encrypting message for verification email failed\n%s\n%s', error, publicKeyArmored); 93 | throw Boom.boomify(error, {statusCode: 400, message: 'Encrypting message for verification email failed.'}); 94 | } 95 | } 96 | 97 | /** 98 | * A generic method to send an email message via nodemailer. 99 | * @param {Object} sendoptions object: { from: ..., to: ..., subject: ..., text: ... } 100 | * @return {Promise} reponse object containing SMTP info 101 | */ 102 | async _sendHelper(sendOptions) { 103 | try { 104 | const info = await this._transporter.sendMail(sendOptions); 105 | if (!this._checkResponse(info)) { 106 | log.warning('Message may not have been received: %s', info.response); 107 | } 108 | return info; 109 | } catch (error) { 110 | log.error('Sending message failed\n%s', error); 111 | throw Boom.badImplementation('Sending email to user failed'); 112 | } 113 | } 114 | 115 | /** 116 | * Check if the message was sent successfully according to SMTP 117 | * reply codes: http://www.supermailer.de/smtp_reply_codes.htm 118 | * @param {Object} info info object return from nodemailer 119 | * @return {Boolean} if the message was received by the user 120 | */ 121 | _checkResponse(info) { 122 | return /^2/.test(info.response); 123 | } 124 | } 125 | 126 | module.exports = Email; 127 | -------------------------------------------------------------------------------- /src/modules/mongo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2020 Mailvelope GmbH 3 | * Licensed under the GNU Affero General Public License version 3 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const log = require('../lib/log'); 9 | const {MongoClient} = require('mongodb'); 10 | 11 | /** 12 | * A simple wrapper around the official MongoDB client. 13 | */ 14 | class Mongo { 15 | /** 16 | * Initializes the database client by connecting to the MongoDB. 17 | * @param {String} uri The mongodb uri 18 | * @param {String} user The databse user 19 | * @param {String} pass The database user's password 20 | * @return {Promise} 21 | */ 22 | async init({uri, user, pass}) { 23 | log.info('Connecting to MongoDB ...'); 24 | const url = `mongodb://${user}:${pass}@${uri}`; 25 | this._client = new MongoClient(url); 26 | this._client.on('commandFailed', event => log.error('MongoDB command failed\n%s', event)); 27 | await this._client.connect(); 28 | this._db = this._client.db(); 29 | } 30 | 31 | /** 32 | * Cleanup by closing the connection to the database. 33 | * @return {Promise} 34 | */ 35 | disconnect() { 36 | return this._client.close(); 37 | } 38 | 39 | /** 40 | * Create the database indexes 41 | * @param {Array} indexSpecs The index specification 42 | * @param {String} type The collection to use e.g. 'publickey' 43 | * @param {Object} [options] create index options 44 | * @return {Promise} The operation result 45 | */ 46 | async createIndexes(indexSpecs, type, options) { 47 | const col = this._db.collection(type); 48 | return col.createIndexes(indexSpecs, options); 49 | } 50 | 51 | /** 52 | * Inserts a single document. 53 | * @param {Object} document Inserts a single document 54 | * @param {String} type The collection to use e.g. 'publickey' 55 | * @return {Promise} The operation result 56 | */ 57 | create(document, type) { 58 | const col = this._db.collection(type); 59 | return col.insertOne(document); 60 | } 61 | 62 | /** 63 | * Inserts a list of documents. 64 | * @param {Array} documents Inserts a list of documents 65 | * @param {String} type The collection to use e.g. 'publickey' 66 | * @return {Promise} The operation result 67 | */ 68 | batch(documents, type) { 69 | const col = this._db.collection(type); 70 | return col.insertMany(documents); 71 | } 72 | 73 | /** 74 | * Update a single document. 75 | * @param {Object} query The query e.g. { _id:'0' } 76 | * @param {Object} diff The attributes to change/set e.g. { foo:'bar' } 77 | * @param {String} type The collection to use e.g. 'publickey' 78 | * @return {Promise} The operation result 79 | */ 80 | update(query, diff, type) { 81 | const col = this._db.collection(type); 82 | return col.updateOne(query, {$set: diff}); 83 | } 84 | 85 | /** 86 | * Read a single document. 87 | * @param {Object} query The query e.g. { _id:'0' } 88 | * @param {String} type The collection to use e.g. 'publickey' 89 | * @return {Promise} The document object 90 | */ 91 | get(query, type) { 92 | const col = this._db.collection(type); 93 | return col.findOne(query); 94 | } 95 | 96 | /** 97 | * Count documents. 98 | * @param {Object} query The query e.g. { _id:'0' } 99 | * @param {String} type The collection to use e.g. 'publickey' 100 | * @return {Promise} The number of found documents 101 | */ 102 | count(query, type) { 103 | const col = this._db.collection(type); 104 | return col.count(query); 105 | } 106 | 107 | /** 108 | * Read multiple documents at once. 109 | * @param {Object} query The query e.g. { foo:'bar' } 110 | * @param {String} type The collection to use e.g. 'publickey' 111 | * @return {Promise} An array of document objects 112 | */ 113 | list(query, type) { 114 | const col = this._db.collection(type); 115 | return col.find(query).toArray(); 116 | } 117 | 118 | /** 119 | * Delete all documents matching a query. 120 | * @param {Object} query The query e.g. { _id:'0' } 121 | * @param {String} type The collection to use e.g. 'publickey' 122 | * @return {Promise} The operation result 123 | */ 124 | remove(query, type) { 125 | const col = this._db.collection(type); 126 | return col.deleteMany(query); 127 | } 128 | 129 | /** 130 | * Clear all documents of a collection. 131 | * @param {String} type The collection to use e.g. 'publickey' 132 | * @return {Promise} The operation result 133 | */ 134 | clear(type) { 135 | const col = this._db.collection(type); 136 | return col.deleteMany({}); 137 | } 138 | 139 | /** 140 | * Aggregate documents from a collection 141 | * @param {Array} pipeline The aggregation pipeline 142 | * @param {String} type The collection to use e.g. 'publickey' 143 | * @return {Promise>} The operation result 144 | */ 145 | aggregate(pipeline, type) { 146 | const col = this._db.collection(type); 147 | return col.aggregate(pipeline); 148 | } 149 | 150 | /** 151 | * Replace one document 152 | * @param {Object} filter The filter used to select the document to replace 153 | * @param {Object} replacement The Document that replaces the matching document 154 | * @param {String} type The collection to use e.g. 'publickey' 155 | * @return {Promise} 156 | */ 157 | replace(filter, replacement, type) { 158 | const col = this._db.collection(type); 159 | return col.replaceOne(filter, replacement); 160 | } 161 | } 162 | 163 | module.exports = Mongo; 164 | -------------------------------------------------------------------------------- /src/route/hkp.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2020 Mailvelope GmbH 3 | * Licensed under the GNU Affero General Public License version 3 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const Boom = require('@hapi/boom'); 9 | const util = require('../lib/util'); 10 | const openpgp = require('openpgp'); 11 | 12 | /** 13 | * An implementation of the OpenPGP HTTP Keyserver Protocol (HKP) 14 | * See https://tools.ietf.org/html/draft-shaw-openpgp-hkp-00 15 | */ 16 | class HKP { 17 | /** 18 | * Create an instance of the HKP server 19 | * @param {Object} publicKey - an instance of the public key service 20 | */ 21 | constructor(publicKey) { 22 | this._publicKey = publicKey; 23 | } 24 | 25 | /** 26 | * Public key upload via http POST 27 | * @param {Object} request - hapi request object 28 | * @param {Object} h - hapi response toolkit 29 | */ 30 | async add(request, h) { 31 | const {keytext: publicKeyArmored} = request.payload; 32 | if (!publicKeyArmored) { 33 | return Boom.badRequest('No key found'); 34 | } 35 | const origin = util.origin(request); 36 | await this._publicKey.put({publicKeyArmored, origin, i18n: request.i18n}); 37 | return h.response('Upload successful. Check your inbox to verify your email address.').code(200); 38 | } 39 | 40 | /** 41 | * Public key lookup via http GET 42 | * @param {Object} request - hapi request object 43 | * @param {Object} h - hapi response toolkit 44 | */ 45 | async lookup(request, h) { 46 | const params = this.parseQueryString(request); 47 | const key = await this._publicKey.get({...params, i18n: request.i18n}); 48 | if (params.op === 'get') { 49 | if (params.mr) { 50 | return h.response(key.publicKeyArmored) 51 | .header('Content-Type', 'application/pgp-keys; charset=utf-8') 52 | .header('Content-Disposition', 'attachment; filename=openpgp-key.asc'); 53 | } else { 54 | return h.view('key-armored', {query: params, key}); 55 | } 56 | } else if (['index', 'vindex'].includes(params.op)) { 57 | const VERSION = 1; 58 | const COUNT = 1; // number of keys 59 | const fp = key.fingerprint.toUpperCase(); 60 | let algo; 61 | try { 62 | algo = openpgp.enums.write(openpgp.enums.publicKey, key.algorithm); 63 | } catch (e) { 64 | algo = key.algorithm.includes('rsa') ? 1 : ''; 65 | } 66 | const created = key.created ? (key.created.getTime() / 1000) : ''; 67 | const keySize = key.keySize ? key.keySize : ''; 68 | let body = `info:${VERSION}:${COUNT}\npub:${fp}:${algo}:${keySize}:${created}::\n`; 69 | for (const uid of key.userIds) { 70 | if (uid.verified) { 71 | body += `uid:${encodeURIComponent(`${uid.name} <${uid.email}>`)}:::\n`; 72 | } 73 | } 74 | return h.response(body).type('text/plain'); 75 | } 76 | } 77 | 78 | /** 79 | * Parse the query string for a lookup request and set a corresponding 80 | * error code if the requests is not supported or invalid. 81 | * @param {Object} query - hapi request query object 82 | * @return {Object} - query parameters or undefined for an invalid request 83 | */ 84 | parseQueryString({query}) { 85 | const params = { 86 | op: query.op, // operation ... only 'get' is supported 87 | mr: query.options === 'mr' // machine readable 88 | }; 89 | if (!['get', 'index', 'vindex'].includes(params.op)) { 90 | throw Boom.notImplemented('Method not implemented'); 91 | } 92 | this.parseSearch(query.search, params); 93 | if (!params.keyId && !params.fingerprint && !params.email) { 94 | throw Boom.badRequest('Invalid search parameter'); 95 | } 96 | return params; 97 | } 98 | 99 | /** 100 | * Parse the search parameter 101 | * @param {String} search Query parameter search 102 | * @param {Object} params Map with results 103 | */ 104 | parseSearch(search, params) { 105 | if (!search || !util.isString(search)) { 106 | return; 107 | } 108 | search = search.replaceAll(/\s/g, ''); 109 | if (this.checkId(search)) { 110 | const id = search.replace(/^0x/, ''); 111 | params.keyId = util.isKeyId(id) ? id : undefined; 112 | params.fingerprint = util.isFingerPrint(id) ? id : undefined; 113 | return; 114 | } 115 | if (search.startsWith('<') && search.endsWith('>')) { 116 | search = search.slice(1, -1); 117 | } 118 | if (util.isEmail(search)) { 119 | params.email = search; 120 | } 121 | } 122 | 123 | /** 124 | * Checks for a valid key id in the query string. A key must be prepended 125 | * with '0x' and can be between 16 and 40 hex characters long. 126 | * @param {String} id - key id 127 | * @return {Boolean} - if the key id is valid 128 | */ 129 | checkId(id) { 130 | return /^(?:0x)?[a-fA-F0-9]{16,40}$/.test(id); 131 | } 132 | } 133 | 134 | exports.plugin = { 135 | name: 'HKP', 136 | async register(server, options) { 137 | const hkp = new HKP(server.app.publicKey); 138 | 139 | const routeOptions = { 140 | bind: hkp, 141 | cors: options.server.cors, 142 | security: options.server.security, 143 | ext: { 144 | onPreResponse: { 145 | method({response}, h) { 146 | if (!response.isBoom) { 147 | return h.continue; 148 | } 149 | return h.response(response.message).code(response.output.statusCode).type('text/plain'); 150 | } 151 | } 152 | } 153 | }; 154 | 155 | server.route({ 156 | method: 'POST', 157 | path: '/pks/add', 158 | handler: hkp.add, 159 | options: routeOptions 160 | }); 161 | 162 | server.route({ 163 | method: 'GET', 164 | path: '/pks/lookup', 165 | handler: hkp.lookup, 166 | options: routeOptions 167 | }); 168 | } 169 | }; 170 | -------------------------------------------------------------------------------- /src/lib/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2020 Mailvelope GmbH 3 | * Licensed under the GNU Affero General Public License version 3 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const crypto = require('crypto'); 9 | 10 | /** 11 | * Checks for a valid string 12 | * @param {} data The input to be checked 13 | * @return {boolean} If data is a string 14 | */ 15 | exports.isString = function(data) { 16 | return typeof data === 'string' || String.prototype.isPrototypeOf(data); // eslint-disable-line no-prototype-builtins 17 | }; 18 | 19 | /** 20 | * Parse string into number 21 | * @param {String} number 22 | * @return {Number|undefined} 23 | */ 24 | exports.parseNumber = function(number) { 25 | const integer = parseInt(number); 26 | return isNaN(integer) ? undefined : integer; 27 | }; 28 | 29 | /** 30 | * Cast string to a boolean value 31 | * @param {} data The input to be checked 32 | * @return {boolean} If data is true 33 | */ 34 | exports.isTrue = function(data) { 35 | if (this.isString(data)) { 36 | return data === 'true'; 37 | } else { 38 | return Boolean(data); 39 | } 40 | }; 41 | 42 | /** 43 | * Checks for a valid long key id which is 16 hex chars long. 44 | * @param {string} data The key id 45 | * @return {boolean} If the key id is valid 46 | */ 47 | exports.isKeyId = function(data) { 48 | if (!this.isString(data)) { 49 | return false; 50 | } 51 | return /^[a-fA-F0-9]{16}$/.test(data); 52 | }; 53 | 54 | /** 55 | * Checks for a valid version 4 fingerprint which is 40 hex chars long. 56 | * @param {string} data The key id 57 | * @return {boolean} If the fingerprint is valid 58 | */ 59 | exports.isFingerPrint = function(data) { 60 | if (!this.isString(data)) { 61 | return false; 62 | } 63 | return /^[a-fA-F0-9]{40}$/.test(data); 64 | }; 65 | 66 | /** 67 | * Checks for a valid email address. 68 | * @param {string} data The email address 69 | * @return {boolean} If the email address if valid 70 | */ 71 | exports.isEmail = function(data) { 72 | if (!this.isString(data)) { 73 | return false; 74 | } 75 | const re = /^[+a-zA-Z0-9_.!#$%&'*\/=?^`{|}~-]+@([a-zA-Z0-9-]+\.)+[a-zA-Z0-9]{2,63}$/; 76 | return re.test(data); 77 | }; 78 | 79 | /** 80 | * Checks for a valid nonce. 81 | * @param {string} data The nonce 82 | * @return {boolean} If the nonce is valid 83 | */ 84 | exports.isNonce = function(data) { 85 | return this.isString(data) && data.length === 32; 86 | }; 87 | 88 | /** 89 | * Normalize email address to lowercase. 90 | * @param {string} email The email address 91 | * @return {string} lowercase email address 92 | */ 93 | exports.normalizeEmail = function(email) { 94 | if (email) { 95 | email = email.toLowerCase(); 96 | } 97 | return email; 98 | }; 99 | 100 | /** 101 | * Generate a cryptographically secure random hex string. If no length is 102 | * provided a 32 char hex string will be generated by default. 103 | * @param {number} bytes (optional) The number of random bytes 104 | * @return {string} The random bytes in hex (twice as long as bytes) 105 | */ 106 | exports.random = function(bytes = 16) { 107 | return crypto.randomBytes(bytes).toString('hex'); 108 | }; 109 | 110 | /** 111 | * Check if the user is connecting over a plaintext http connection. 112 | * This can be used as an indicator to upgrade their connection to https. 113 | * @param {Object} request - hapi request object 114 | * @return {boolean} If http is used 115 | */ 116 | exports.checkHTTP = function(request) { 117 | return request.server.info.protocol === 'http' && request.headers['x-forwarded-proto'] === 'http'; 118 | }; 119 | 120 | /** 121 | * Check if the user is connecting over a https connection. 122 | * @param {Object} request - hapi request object 123 | * @return {boolean} If https is used 124 | */ 125 | exports.checkHTTPS = function(request) { 126 | return request.server.info.protocol === 'https' || request.headers['x-forwarded-proto'] === 'https'; 127 | }; 128 | 129 | /** 130 | * Get the server's own origin host and protocol. Required for sending 131 | * verification links via email. If the PORT environmane variable 132 | * is set, we assume the protocol to be 'https', since the AWS loadbalancer 133 | * speaks 'https' externally but 'http' between the LB and the server. 134 | * @param {Object} request - hapi request object 135 | * @return {Object} The server origin 136 | */ 137 | exports.origin = function(request) { 138 | return { 139 | protocol: this.checkHTTPS(request) ? 'https' : request.server.info.protocol, 140 | host: request.info.host 141 | }; 142 | }; 143 | 144 | /** 145 | * Helper to create urls pointing to this server 146 | * @param {Object} origin The server's origin 147 | * @param {string} resource (optional) The resource to point to 148 | * @return {string} The complete url 149 | */ 150 | exports.url = function(origin, resource) { 151 | return `${origin.protocol}://${origin.host}${resource || ''}`; 152 | }; 153 | 154 | /** 155 | * Validity status of a key 156 | * @type {Object} 157 | */ 158 | exports.KEY_STATUS = { 159 | invalid: 0, 160 | expired: 1, 161 | revoked: 2, 162 | valid: 3, 163 | no_self_cert: 4 164 | }; 165 | 166 | /** 167 | * Asynchronous wrapper for Array.prototype.filter() 168 | * @param {Array} array 169 | * @param {Function} asyncFilterFn 170 | * @return {Promise} 171 | */ 172 | exports.filterAsync = async function(array, asyncFilterFn) { 173 | const promises = array.map(async item => await asyncFilterFn(item) && item); 174 | const result = await Promise.all(promises); 175 | return result.filter(item => item); 176 | }; 177 | 178 | /** 179 | * Return Date one day in the future 180 | * @return {Date} 181 | */ 182 | exports.getTomorrow = function() { 183 | const now = new Date(); 184 | now.setDate(now.getDate() + 1); 185 | return now; 186 | }; 187 | -------------------------------------------------------------------------------- /test/fixtures/key6.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | xjMEZR7BxRYJKwYBBAHaRw8BAQdAmQqZ2+JpXmfpvFc8Yq05KR45oie+RDff 4 | 8xij0EM74TLCeAQgFgoAIBYhBJBQf7IpZY9x896WqEwDpHNixbTMBQJlaww8 5 | Ah0AAAoJEEwDpHNixbTMdE0A/iJfn5XCE4r+K+tPwr8awoCjC61fCKjBpXaB 6 | qizd1Q1IAQD0kJAdKyKWR5PZuQ56p/UDe367q3J4vjEblX++o22pCcKaBB8W 7 | CgA4FiEEkFB/sillj3Hz3paoTAOkc2LFtMwFAmVrCuoXDIABXQMfHUELmA+8 8 | AG4yAsE00HlwGTQCBwAAFAkQTAOkc2LFtMwJZEwDpHNixbTMZ1sA/0B9YxsT 9 | +WC39seGckXlADm1f20vDGnuFnr+VCjCa1+cAP45c28oH08bP/6M5KYiR7ZR 10 | IrlcGbBQOs9NgKMcIe5wAc0lTWFpbHZlbG9wZSBEZW1vIDxkZW1vQG1haWx2 11 | ZWxvcGUuY29tPsKSBBAWCgBEBYJlHsHFBYkJY16ABAsJBwgJkEwDpHNixbTM 12 | AxUICgQWAAIBAhkBApsDAh4BFiEEkFB/sillj3Hz3paoTAOkc2LFtMwAAO2P 13 | AQD12YU9Be+PPwcA6wCrqkGRjypYeLLYQLrGhWQg6jR0RgD+PnN1zhfI9Xoe 14 | 1VquLwZ1QKoD2S4pY3D8dD6fYrToNwvRx8XHwwEQAAEBAAAAAAAAAAAAAAAA 15 | /9j/4AAQSkZJRgABAQAAeAB4AAD/4QCMRXhpZgAATU0AKgAAAAgABQESAAMA 16 | AAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdp 17 | AAQAAAABAAAAWgAAAAAAAAB4AAAAAQAAAHgAAAABAAOgAQADAAAAAQABAACg 18 | AgAEAAAAAQAAAO2gAwAEAAAAAQAAACQAAAAA/+0AOFBob3Rvc2hvcCAzLjAA 19 | OEJJTQQEAAAAAAAAOEJJTQQlAAAAAAAQ1B2M2Y8AsgTpgAmY7PhCfv/AABEI 20 | ACQA7QMBIgACEQEDEQH/xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJ 21 | Cgv/xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGR 22 | oQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNU 23 | VVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaan 24 | qKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T1 25 | 9vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1EQAC 26 | AQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS 27 | 8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNk 28 | ZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1 29 | tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2wBD 30 | ABwcHBwcHDAcHDBEMDAwRFxEREREXHRcXFxcXHSMdHR0dHR0jIyMjIyMjIyo 31 | qKioqKjExMTExNzc3Nzc3Nzc3Nz/2wBDASIkJDg0OGA0NGDmnICc5ubm5ubm 32 | 5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ub/ 33 | 3QAEAA//2gAMAwEAAhEDEQA/AOhJwM03Mh/hH5//AFqG+6fpTmbZGW9BmgBu 34 | Zf7o/P8A+tRmX+6Pz/8ArVXMsqr5hORkjt2z7e3rV2gCLMv90fn/APWozL/d 35 | H5//AFqhiuOB5gJyxXdjjrxT0mZmkBU4Q8dOcD60roXMh+Zf7o/P/wCtRmX+ 36 | 6Pz/APrVElz+7R3UgvwMfTPrT/tCYYsCCpAIPXnpRdBzIdmT+6Pz/wDrUZl/ 37 | uj8//rUqybgflYEdj/nFNeQq59FXJ/pTGLmX+6Pz/wDrUZl/uj8//rVXN6kc 38 | DSyhso21uBnP+eake6jSUREEn5RkdBuzj+VAEmZf7o/P/wCtRmX+6Pz/APrV 39 | CbyEednP7kZb3+n8qR72JImmYMAjbSO+fz9OaAJ8y/3R+f8A9ajMv90fn/8A 40 | WoEqtK0IzlQCT25zj+VVv7QixuKvt3Fd2OMg49c0AWcy/wB0fn/9ajMn90fn 41 | /wDWqJbpSHyjq0eCVxk89MYzUcl4qo4dXjZULdBnA7jkj86ALOZf7o/P/wCt 42 | RmX+6Pz/APrVViuj5zxyZOZNqnjA+UHB/WpXvIkLLhiVYLwOrEZwKAJcyf3R 43 | +f8A9ajMn90fn/8AWqpLdkp+7yjrIisGAzhiPqORUjXsaljtYop2l8fKD+ef 44 | 0oAnzL/dH5//AFqMy/3R+f8A9aoGvUVpFCO3lnDEAccZz1pzXaAqsatIWXdh 45 | fT15xQBLmX+6Pz/+tRmX+6Pz/wDrUsUqTRiVDlTUMd3HIygKwV+FYjg/596A 46 | Jcyf3R+f/wBajMv90fn/APWqBb2NyvysEc4VyOCfzz+lN/tCLG4q+3cV3Y4y 47 | Dj1zQBZzJ/dH5/8A1qMyf3R+f/1qbFOsrMm1kZMZDe/Q8ZqegD//0N4/dP0p 48 | Lj/Vfiv8xQeQRTyUddrDIPbFJgU8R/3B+VW4OYU/3RUflW/9wflUwZQMDoPa 49 | pjGxKVisIpPJCY5Em78N2akRHV5FI4Y5B/ACpt6+/wCVG9ff8qqwcpRxIiwK 50 | VwynGMjnC1JsdjI7JndgbSewqyShIJHTpxS719/ypcocpBCsibiQdvG1Scke 51 | vf8ArSmN23ZH3mH/AHyKm3r7/lRvX3/KmkNKxQe2d7skj90w3H/ewVxj6VHH 52 | bTm0kEo/etgjnugG38yM/jWnvX3/ACo3r7/lTGZRtJz5WV+/nzeRx827/EVM 53 | 9s73ZJH7phuP+9grjH0q/vX3/Kjevv8AlQBQtI7iFN0qZd2AbkcKBjP6frTR 54 | bzfZlj2/MJt2Mjpvz/KtHevv+VG9ff8AKgDPuLed5JmjHDKg64zgnI9uKqTW 55 | kpLNBAEDRsmARnJwcnnFbe9ff8qN6+/5UAZzW0pSYgfN5gkTkc4A/njFNa0l 56 | MEbEEyBzIwBwSWzkAj0zWnvX3/Kjevv+VAGYbZ2UssbKxdD8z7iQpzzyRx9a 57 | RoLjyJLNUyHY4fIwAxzyOuRWpvX3/Kjevv8AlQBloZg9ykUe/c+AcgYO0dc9 58 | qT7HJCyEBnAjVDsbacr+I4rTBjUkqMZOTgdTTt6+/wCVAFaGNoo0hEeFIYt8 59 | 2dpPPfk5zUVutykaWzIAFG0vkYIHTA65+tXt6+/5Ub19/wAqAMxIbgxQ2rJg 60 | RMpL5GCF6YHXmnC3m+zLHt+YTbsZHTfn+VaO9ff8qN6+/wCVAECRuLuSUj5W 61 | VQD7jOf51Zpu9ff8qN6+/wCVAH//0d2kpaSgAooooAWikpaACiikoAWikpaA 62 | EooooAKWkooAWikpaACikooAWiikoAKKKKAFopKKAFoopKAFopKWgAopKKAP 63 | /9nClgQTFgoAPhYhBJBQf7IpZY9x896WqEwDpHNixbTMBQJlUmGSAhsDBQkJ 64 | Y16ABQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAAoJEEwDpHNixbTMUfsA/3+j 65 | XKARC17DYpsXgUjSJwtvNJTHFth7lIAp0C5BrlakAP0XwMWVHiB3p28KCXZV 66 | 7MfTIx2+eWkmt9PIDTgkA/J1Cc0NV2l0aG91dCBFbWFpbMKWBBMWCgA+FiEE 67 | kFB/sillj3Hz3paoTAOkc2LFtMwFAmVrAP0CGwMFCQljXoAFCwkIBwMFFQoJ 68 | CAsFFgIDAQACHgECF4AACgkQTAOkc2LFtMwApgD+N4ee3nhDSumps8q2oMp5 69 | GwMZNNQ1t4J8lbm/bjBRo0YBAJ81Zq3gdybOFrcbsBZafBRHmNtpbplh7LQg 70 | rdFbt18MzSBJbnZhbGlkIDxpbnZhbGlkQG1haWx2ZWxvcGUuY29tPsKWBBMW 71 | CgA+FiEEkFB/sillj3Hz3paoTAOkc2LFtMwFAmVrATICGwMFCQljXoAFCwkI 72 | BwMFFQoJCAsFFgIDAQACHgECF4AACgkQTAOkc2LFtMyNnAD7B4MetfSeCb8h 73 | WJFaB+aqZGEZihfJ431ADvvBY7mVd20BAPzrumn8QcyHm3XTxOl7EMGlTjXM 74 | LrBcAvl6i8s7LoANzSBSZXZva2VkIDxyZXZva2VkQG1haWx2ZWxvcGUuY29t 75 | PsJ4BDAWCgAgFiEEkFB/sillj3Hz3paoTAOkc2LFtMwFAmVrBHUCHQAACgkQ 76 | TAOkc2LFtMyAIAEAvzhjYu0GF4pZalOSMVyu4xwmKg16Zs2+fCcVHSNY7dQA 77 | /0O8Pyrn/2Gey3f6chjRzkrtZ8IIf7swBMjMB5USZJ0DwngEMBYKACAWIQSQ 78 | UH+yKWWPcfPelqhMA6RzYsW0zAUCZWsEQwIdIAAKCRBMA6RzYsW0zEwoAP9E 79 | g5QX8IU9B5ZKFL49cYGLuQUm7it2xk8UbqnE/Hp3MQD/R4hSJoQFh7dkrrM9 80 | C86wgd2eTLgdEQ3Qtn78/LtMQA/CeAQwFgoAIBYhBJBQf7IpZY9x896WqEwD 81 | pHNixbTMBQJlawQhAh0AAAoJEEwDpHNixbTML68BAJ74hg+KFQyesruEnozu 82 | U0eGp2SEdoZ/NFL15hwVD0h7AP4wxlfiC2lgCXAuRJMc4c6ZKN3tfsKDa5jF 83 | 4T+XWWb2DcJ4BDAWCgAgFiEEkFB/sillj3Hz3paoTAOkc2LFtMwFAmVrAaMC 84 | HQAACgkQTAOkc2LFtMxSJwD/erY1ZZ+t400VzpoKbllhntpzXRbsJZkU/s6J 85 | 6/4ExBEA/jhqh75Cgm23KLk/sHs0PdXU9dP42aQ+ngDP2ANNpJAGwpYEExYK 86 | AD4WIQSQUH+yKWWPcfPelqhMA6RzYsW0zAUCZWsBZgIbAwUJCWNegAULCQgH 87 | AwUVCgkICwUWAgMBAAIeAQIXgAAKCRBMA6RzYsW0zFCLAP92YExGOMaQNCMA 88 | KssvajMFVGD3TX29yZQkcB0nHG9cIAD+PkB6f0gSNBAdnU65HIU+yGnBQswn 89 | UAv58Xny/71BTg/OOARlHsHFEgorBgEEAZdVAQUBAQdALk8LdnEkmZhi9Qb4 90 | ZPAee3rqPpzf/Y++Dr2OhjQ+DQkDAQgHwn4EGBYIADAFgmUewcUFiQljXoAJ 91 | kEwDpHNixbTMApsMFiEEkFB/sillj3Hz3paoTAOkc2LFtMwAANIFAQD0lgFx 92 | vRRNNEqC6hDIYiFGKWXs5oDMS+A/csbqKgmy/AEArV3G2a3KYN77tapuLNNi 93 | lwU9uCA7FXZ0xawh8Nq4AgHOjQRlawVUAQQAtymo9zu5Xf1MGk6QwWG/Aa+j 94 | Uvebtt5gT4QEDTAPcPfa5079ed4bCylK2Sedr9QsPUKwyEL0sLfIjOp6S5D/ 95 | /FI8npqY/PFppv+8rcNJwD8pSZCrCOE6Mr7g+S/49XQT1cPlB1tSwQLXxaRD 96 | HkEZls7RszipSrqYwmjdIx2PCe8AEQEAAcJ4BCgWCgAgFiEEkFB/sillj3Hz 97 | 3paoTAOkc2LFtMwFAmVrBlQCHQEACgkQTAOkc2LFtMy4VQEArXMnEXAFaZ/O 98 | F09kkVhEUFBBswphTCFjjrcGFidAIeMBAKNJD9ShvhGo0xJSd9BJzmaOZwMH 99 | +RDc+KOX+SAwcAsAwsBzBBgWCgAmAhsCFiEEkFB/sillj3Hz3paoTAOkc2LF 100 | tMwFAmVrBjIFCQtHNd4Av7QgBBkBCgAdFiEEVlLMzBJby2JIA4Qa9REqcoJI 101 | eRQFAmVrBVQACgkQ9REqcoJIeRR88QP+MQXVxnz3gDsYkY1KgP4LfJBwS3OC 102 | r5c5QZgTPt8N1MbF1S8tWiSbv2YcNFlnfsQ50yvtQgU3NuDmS59C0smlTsBi 103 | 1gthmocaybm5dI/HwRckjwtZoLYsglRQtmozp3j5P7mjfQ68hGmFN6A/BaX5 104 | W6bjbAhc4D4xvlyOm1hbEcAJEEwDpHNixbTMn6cA/RE9EmYSfwMezWw76s0g 105 | TSoNJB018OMfMV3nR9rzXoaVAP4mHcLMExLqrRlpNPHxM5Lp8DvVeXaYiYlZ 106 | 7Ha4MmlQBA== 107 | =4B5W 108 | -----END PGP PUBLIC KEY BLOCK----- 109 | -------------------------------------------------------------------------------- /src/modules/pgp.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2020 Mailvelope GmbH 3 | * Licensed under the GNU Affero General Public License version 3 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const Boom = require('@hapi/boom'); 9 | const log = require('../lib/log'); 10 | const util = require('../lib/util'); 11 | const openpgp = require('openpgp'); 12 | 13 | const {KEY_STATUS} = util; 14 | 15 | /** 16 | * A simple wrapper around OpenPGP.js 17 | */ 18 | class PGP { 19 | constructor(purify) { 20 | this.purify = purify; 21 | openpgp.config.showVersion = false; 22 | openpgp.config.showComment = false; 23 | } 24 | 25 | /** 26 | * Parse an ascii armored pgp key block and get its parameters. 27 | * @param {String} publicKeyArmored ascii armored pgp key block 28 | * @return {Promise} public key document to persist 29 | */ 30 | async parseKey(publicKeyArmored) { 31 | const key = await this.readKey(publicKeyArmored); 32 | if (key.isPrivate()) { 33 | log.error('Attempted private key upload'); 34 | throw Boom.badRequest('Error uploading private key. Please keep your private key secret and never upload it to key servers. Only public keys accepted.'); 35 | } 36 | await this.purify.purifyKey(key); 37 | // verify key 38 | const verifyDate = util.getTomorrow(); 39 | const keyStatus = await this.verifyKey(key, verifyDate); 40 | if (keyStatus === KEY_STATUS.invalid) { 41 | log.error('Invalid PGP key: primary key verification failed\n%s', key.armor()); 42 | throw Boom.badRequest('Invalid PGP key. Verification of the primary key failed.'); 43 | } 44 | // check for at least one valid user ID 45 | const userIds = await this.parseUserIds(key, verifyDate); 46 | if (!userIds.length) { 47 | log.error('Invalid PGP key: no valid user IDs with email address found\n%s', publicKeyArmored); 48 | throw Boom.badRequest('Invalid PGP key: no valid user ID with email address found'); 49 | } 50 | // get algorithm details from primary key 51 | const keyInfo = key.getAlgorithmInfo(); 52 | this.purify.checkMaxKeySize(key); 53 | // public key document that is stored in the database 54 | return { 55 | keyId: key.getKeyID().toHex(), 56 | fingerprint: key.getFingerprint(), 57 | userIds, 58 | created: key.getCreationTime(), 59 | uploaded: new Date(), 60 | algorithm: keyInfo.algorithm, 61 | keySize: keyInfo.bits, 62 | publicKeyArmored: key.armor() 63 | }; 64 | } 65 | 66 | /** 67 | * Verify key 68 | * @param {PublicKey} key 69 | * @param {Date} verifyDate The verification date 70 | * @return {Promise} The KEY_STATUS 71 | */ 72 | async verifyKey(key, verifyDate = new Date()) { 73 | try { 74 | await key.verifyPrimaryKey(verifyDate); 75 | return KEY_STATUS.valid; 76 | } catch (e) { 77 | switch (e.message) { 78 | case 'Primary key is revoked': 79 | case 'Primary user is revoked': 80 | return KEY_STATUS.revoked; 81 | case 'Primary key is expired': 82 | return KEY_STATUS.expired; 83 | default: 84 | return KEY_STATUS.invalid; 85 | } 86 | } 87 | } 88 | 89 | /** 90 | * Parse user IDs and return the ones that are valid or revoked and contain an email address 91 | * @param {PublicKey} key 92 | * @param {Date} verifyDate Verify user IDs at this point in time 93 | * @return {Promise} An array of user ID objects 94 | */ 95 | async parseUserIds(key, verifyDate = new Date()) { 96 | const result = []; 97 | for (const user of key.users) { 98 | const userStatus = await this.verifyUser(user, verifyDate); 99 | const {email, name} = this.purify.parseUserID(user.userID); 100 | if (userStatus !== KEY_STATUS.invalid && email) { 101 | result.push({ 102 | status: userStatus, 103 | name, 104 | email, 105 | verified: false 106 | }); 107 | } 108 | } 109 | return result; 110 | } 111 | 112 | async verifyUser(user, verifyDate = new Date()) { 113 | try { 114 | await user.verify(verifyDate); 115 | return KEY_STATUS.valid; 116 | } catch (e) { 117 | switch (e.message) { 118 | case 'Self-certification is revoked': 119 | return KEY_STATUS.revoked; 120 | case 'No self-certifications found': 121 | return user.revocationSignatures.length ? KEY_STATUS.revoked : KEY_STATUS.no_self_cert; 122 | case 'Self-certification is invalid: Signature is expired': 123 | return KEY_STATUS.expired; 124 | default: 125 | return KEY_STATUS.invalid; 126 | } 127 | } 128 | } 129 | 130 | /** 131 | * Remove user IDs from armored key block which are not in array of user IDs 132 | * @param {Array} userIds user IDs to be kept 133 | * @param {String} armoredKey armored key block to be filtered 134 | * @param {Boolean} verifyEncryptionKey verify that key has encryption capabilities 135 | * @return {Promise} filtered amored key block 136 | */ 137 | async filterKeyByUserIds(userIds, armoredKey, verifyEncryptionKey) { 138 | const emails = userIds.map(({email}) => email); 139 | const key = await this.readKey(armoredKey); 140 | try { 141 | if (verifyEncryptionKey) { 142 | await key.getEncryptionKey(null, util.getTomorrow()); 143 | } 144 | } catch (e) { 145 | log.error('Invalid PGP key: no valid encryption key found\n%s\n%s', e, armoredKey); 146 | throw Boom.badRequest(`Invalid PGP key. No valid encryption key found: ${e.message}`); 147 | } 148 | key.users = key.users.filter(({userID}) => emails.includes(this.purify.parseUserID(userID).email)); 149 | return key.armor(); 150 | } 151 | 152 | /** 153 | * Merge (update) armored key blocks 154 | * @param {String} srcArmored source amored key block 155 | * @param {String} dstArmored destination armored key block 156 | * @return {Promise} merged armored key block 157 | */ 158 | async updateKey(srcArmored, dstArmored) { 159 | const srcKey = await this.readKey(srcArmored); 160 | const dstKey = await this.readKey(dstArmored); 161 | const updatedKey = await dstKey.update(srcKey); 162 | this.purify.limitNumOfCertificates(updatedKey); 163 | this.purify.checkMaxKeySize(updatedKey); 164 | return updatedKey.armor(); 165 | } 166 | 167 | /** 168 | * Remove user ID from armored key block 169 | * @param {String} email email of user ID to be removed 170 | * @param {String} armoredKey amored key block to be filtered 171 | * @return {Promise} filtered armored key block 172 | */ 173 | async removeUserId(email, armoredKey) { 174 | const key = await this.readKey(armoredKey); 175 | key.users = key.users.filter(({userID}) => this.purify.parseUserID(userID).email !== email); 176 | return key.armor(); 177 | } 178 | 179 | async readKey(armoredKey) { 180 | if (!/-----BEGIN\sPGP\sPUBLIC\sKEY\sBLOCK-----/.test(armoredKey)) { 181 | log.error('No armored PGP key\n%s', armoredKey); 182 | throw Boom.badRequest('Malformed PGP key. Keys need to start with an armor header line: -----BEGIN PGP PUBLIC KEY BLOCK-----'); 183 | } 184 | try { 185 | return await openpgp.readKey({armoredKey}); 186 | } catch (e) { 187 | log.error('Failed to parse PGP key\n%s\n%s', e, armoredKey); 188 | throw Boom.badRequest(`Failed to read PGP key: ${e.message}`); 189 | } 190 | } 191 | } 192 | 193 | module.exports = PGP; 194 | -------------------------------------------------------------------------------- /test/fixtures/key4.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | 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 49 | ZFXRFPEmGmdLGZcxFHcsXgmmfg== 50 | =XwSC 51 | -----END PGP PUBLIC KEY BLOCK----- 52 | -----BEGIN PGP PRIVATE KEY BLOCK----- 53 | 54 | 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 154 | lzEUdyxeCaZ+ 155 | =owDa 156 | -----END PGP PRIVATE KEY BLOCK----- 157 | -------------------------------------------------------------------------------- /src/modules/purify-key.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2023 Mailvelope GmbH 3 | * Licensed under the GNU Affero General Public License version 3 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const Boom = require('@hapi/boom'); 9 | const {filterAsync} = require('../lib/util'); 10 | const {enums} = require('openpgp'); 11 | const goog = require('../lib/closure-library/closure/goog/emailaddress'); 12 | const util = require('../lib/util'); 13 | 14 | /** 15 | * Purify keys to avoid malicious abuse of key server following techniques from: 16 | * https://datatracker.ietf.org/doc/html/draft-dkg-openpgp-abuse-resistant-keystore-06 17 | */ 18 | class PurifyKey { 19 | constructor(config) { 20 | this.conf = { 21 | allowedUnhashedSubpackets: new Set([ 22 | enums.signatureSubpacket.issuer, 23 | enums.signatureSubpacket.issuerFingerprint, 24 | enums.signatureSubpacket.embeddedSignature 25 | ]), 26 | ...config 27 | }; 28 | } 29 | 30 | /** 31 | * Evaluate the key and filter out all components that violate policy for abuse resistant key server 32 | * @param {PublicKey} key The key to be purified 33 | * @throws {Error} The key failed the purification 34 | */ 35 | async purifyKey(key) { 36 | if (!this.conf.purifyKey) { 37 | return; 38 | } 39 | this.checkKeyPacket(key); 40 | await this.checkKeySignatures(key); 41 | await this.checkUsers(key); 42 | await this.checkSubkeys(key); 43 | this.limitNumOfCertificates(key); 44 | } 45 | 46 | checkKeyPacket(key) { 47 | if (key.keyPacket.write().length > this.conf.maxSizePacket) { 48 | throw Boom.badRequest(`The primary key packet exceeds the max. allowed size of ${(this.conf.maxSizePacket / 1024).toFixed(2)} kB.`); 49 | } 50 | if (key.keyPacket.version !== 4) { 51 | throw Boom.badRequest('Only keys with v4 primary key packet are supported.'); 52 | } 53 | } 54 | 55 | async checkKeySignatures(key) { 56 | // verify and filter all revocation certifications 57 | key.revocationSignatures = await filterAsync(key.revocationSignatures, cert => this.verifyKeyCerts(key.keyPacket, cert, enums.signature.keyRevocation)); 58 | // remove not allowed unhashed subpackets 59 | key.revocationSignatures.forEach(cert => this.filterUnhashedSubPackets(cert)); 60 | // verify and filter all direct signatures 61 | key.directSignatures = await filterAsync(key.directSignatures, cert => this.verifyKeyCerts(key.keyPacket, cert, enums.signature.key)); 62 | // remove not allowed unhashed subpackets 63 | key.directSignatures.forEach(cert => this.filterUnhashedSubPackets(cert)); 64 | } 65 | 66 | async checkUsers(key) { 67 | // filter out user attribute packets and user IDs without email address 68 | key.users = key.users.filter(user => this.parseUserID(user.userID).email); 69 | if (!key.users.length) { 70 | throw Boom.badRequest('Require at least one user ID with email address.'); 71 | } 72 | // filter out user IDs that exceeds maxSizeUserID bytes 73 | key.users = key.users.filter(user => user.userID.write().length <= this.conf.maxSizeUserID); 74 | if (!key.users.length) { 75 | throw Boom.badRequest(`Size of all user IDs of key exceeds ${this.conf.maxSizeUserID} bytes.`); 76 | } 77 | for (const user of key.users) { 78 | // verify and filter all self certifications 79 | user.selfCertifications = await filterAsync(user.selfCertifications, cert => this.verifyUserCerts(user.mainKey.keyPacket, user.userID, cert, enums.signature.certGeneric)); 80 | // remove not allowed unhashed subpackets 81 | user.selfCertifications.forEach(cert => this.filterUnhashedSubPackets(cert)); 82 | // remove all other certifications 83 | user.otherCertifications = []; 84 | // verify and filter all revocation certifications 85 | user.revocationSignatures = await filterAsync(user.revocationSignatures, cert => this.verifyUserCerts(user.mainKey.keyPacket, user.userID, cert, enums.signature.certRevocation)); 86 | // remove not allowed unhashed subpackets 87 | user.revocationSignatures.forEach(cert => this.filterUnhashedSubPackets(cert)); 88 | } 89 | // user needs at least one self or revocation certification 90 | key.users = key.users.filter(user => user.selfCertifications.length || user.revocationSignatures.length); 91 | // enforce max. number of email addresses per key 92 | if (key.users.length > this.conf.maxNumUserEmail) { 93 | throw Boom.badRequest(`Number of user IDs with email address exceeds allowed max. of ${this.conf.maxNumUserEmail}`); 94 | } 95 | } 96 | 97 | /** 98 | * Additional user ID parsing in case OpenPGP.js email-addresses parser fails 99 | * @param {UserIDPacket} userID 100 | * @return {Object} 101 | */ 102 | parseUserID(userID) { 103 | if (!userID) { 104 | return {}; 105 | } 106 | if (userID.name || userID.email) { 107 | return { 108 | name: userID.name, 109 | email: util.normalizeEmail(userID.email) 110 | }; 111 | } 112 | try { 113 | const emailAddress = goog.format.EmailAddress.parse(userID.userID); 114 | if (emailAddress.isValid()) { 115 | return { 116 | name: emailAddress.getName(), 117 | email: util.normalizeEmail(emailAddress.getAddress()) 118 | }; 119 | } 120 | } catch (e) {} 121 | return {}; 122 | } 123 | 124 | async checkSubkeys(key) { 125 | // filter out subkeys with packet size that exceeds maxSizePacket bytes 126 | key.subkeys = key.subkeys.filter(subkey => subkey.keyPacket.write().length <= this.conf.maxSizePacket); 127 | for (const subkey of key.subkeys) { 128 | // verify and filter all binding signatures 129 | subkey.bindingSignatures = await filterAsync(subkey.bindingSignatures, cert => this.verifySubkeyCerts(subkey.mainKey.keyPacket, subkey.keyPacket, cert, enums.signature.subkeyBinding)); 130 | // remove not allowed unhashed subpackets 131 | subkey.bindingSignatures.forEach(cert => this.filterUnhashedSubPackets(cert)); 132 | // verify and filter all revocation certifications 133 | subkey.revocationSignatures = await filterAsync(subkey.revocationSignatures, cert => this.verifySubkeyCerts(subkey.mainKey.keyPacket, subkey.keyPacket, cert, enums.signature.subkeyRevocation)); 134 | // remove not allowed unhashed subpackets 135 | subkey.revocationSignatures.forEach(cert => this.filterUnhashedSubPackets(cert)); 136 | } 137 | // subkey needs at least one binding or revocation signature 138 | key.subkeys = key.subkeys.filter(subkey => subkey.bindingSignatures.length || subkey.revocationSignatures.length); 139 | // enforce max. number of subkeys per key 140 | if (key.subkeys.length > this.conf.maxNumSubkey) { 141 | throw Boom.badRequest(`Number of subkeys exceeds allowed max. of ${this.conf.maxNumSubkey}`); 142 | } 143 | } 144 | 145 | limitNumOfCertificates(key) { 146 | if (!this.conf.purifyKey) { 147 | return; 148 | } 149 | this.limitRevCerts(key.revocationSignatures, this.conf.maxNumCert); 150 | this.limitCerts(key.directSignatures, this.conf.maxNumCert); 151 | for (const user of key.users) { 152 | this.limitCerts(user.selfCertifications, this.conf.maxNumCert); 153 | this.limitRevUserCerts(user.revocationSignatures, this.conf.maxNumCert); 154 | } 155 | for (const subkey of key.subkeys) { 156 | this.limitCerts(subkey.bindingSignatures, this.conf.maxNumCert); 157 | this.limitRevCerts(subkey.revocationSignatures, this.conf.maxNumCert); 158 | } 159 | } 160 | 161 | limitCerts(certs, maxNum) { 162 | // sort by descending signature creation date 163 | certs.sort((a, b) => b.created - a.created); 164 | if (certs.length > maxNum) { 165 | certs.length = maxNum; 166 | } 167 | } 168 | 169 | limitRevCerts(certs, maxNum) { 170 | // sort by descending hard revocation and ascending signature creation date => oldest hard revocations first 171 | certs.sort((a, b) => this.isHardRevocation(b) - this.isHardRevocation(a) || a.created - b.created); 172 | if (certs.length > maxNum) { 173 | certs.length = maxNum; 174 | } 175 | } 176 | 177 | limitRevUserCerts(certs, maxNum) { 178 | // sort by ascending signature creation date 179 | certs.sort((a, b) => a.created - b.created); 180 | if (certs.length > maxNum) { 181 | certs.length = maxNum; 182 | } 183 | } 184 | 185 | isHardRevocation(cert) { 186 | // "Key is superseded" or "Key is retired and no longer used" is a "soft" revocation 187 | // All other revocations are considered "hard" 188 | return !(cert.reasonForRevocationFlag === enums.reasonForRevocation.keySuperseded || 189 | cert.reasonForRevocationFlag === enums.reasonForRevocation.keyRetired); 190 | } 191 | 192 | async verifyUserCerts(key, userID, cert, type) { 193 | try { 194 | await cert.verify(key, type, {userID, key}, null); 195 | return cert.write().length <= this.conf.maxSizePacket; 196 | } catch (e) {} 197 | } 198 | 199 | async verifyKeyCerts(key, cert, type) { 200 | try { 201 | await cert.verify(key, type, {key}, null); 202 | } catch (e) { 203 | if (e.message !== 'This key is intended to be revoked with an authorized key, which OpenPGP.js does not support.') { 204 | return; 205 | } 206 | } 207 | return cert.write().length <= this.conf.maxSizePacket; 208 | } 209 | 210 | async verifySubkeyCerts(key, bind, cert, type) { 211 | try { 212 | await cert.verify(key, type, {key, bind}, null); 213 | return cert.write().length <= this.conf.maxSizePacket; 214 | } catch (e) {} 215 | } 216 | 217 | filterUnhashedSubPackets(cert) { 218 | // remove all unhashed subpackets except allowed ones 219 | cert.unhashedSubpackets = cert.unhashedSubpackets.filter(packet => this.conf.allowedUnhashedSubpackets.has(packet[0] & 0x7F)); 220 | if (cert.embeddedSignature) { 221 | cert.embeddedSignature.unhashedSubpackets = []; 222 | } 223 | } 224 | 225 | checkMaxKeySize(key) { 226 | if (!this.conf.purifyKey) { 227 | return; 228 | } 229 | const keySize = key.toPacketList().write().length; 230 | if (keySize > this.conf.maxSizeKey) { 231 | throw Boom.badRequest(`The key exceeds the max. allowed key size of ${(this.conf.maxSizeKey / 1024).toFixed(2)} kB.`); 232 | } 233 | } 234 | } 235 | 236 | module.exports = PurifyKey; 237 | -------------------------------------------------------------------------------- /src/lib/closure-library/LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /test/integration/server-test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('../../config/config'); 4 | const fs = require('fs'); 5 | const log = require('../../src/lib/log'); 6 | const Mongo = require('../../src/modules/mongo'); 7 | const nodemailer = require('nodemailer'); 8 | const request = require('supertest'); 9 | const templates = require('../../src/lib/templates'); 10 | 11 | describe('Key Server Integration Tests', function() { 12 | this.timeout(20000); 13 | 14 | const sandbox = sinon.createSandbox(); 15 | let app; 16 | let mongo; 17 | let sendEmailStub; 18 | let publicKeyArmored; 19 | let emailParams; 20 | 21 | const DB_TYPE_PUB_KEY = 'publickey'; 22 | const primaryEmail = 'demo@mailvelope.com'; 23 | const fingerprint = '90507FB229658F71F3DE96A84C03A47362C5B4CC'; 24 | const conf = structuredClone(config); 25 | 26 | before(async () => { 27 | sandbox.stub(log); 28 | publicKeyArmored = fs.readFileSync(`${__dirname}/../fixtures/key2.asc`, 'utf8'); 29 | mongo = new Mongo(); 30 | conf.mongo.uri = `${config.mongo.uri}-int`; 31 | await mongo.init(conf.mongo); 32 | const paramMatcher = sinon.match(params => { 33 | emailParams = params; 34 | return Boolean(params.nonce); 35 | }); 36 | sandbox.spy(templates, 'verifyKey').withArgs(paramMatcher); 37 | sandbox.spy(templates, 'verifyRemove').withArgs(paramMatcher); 38 | sendEmailStub = sandbox.stub().returns(Promise.resolve({response: '250'})); 39 | sendEmailStub.withArgs(sinon.match(sendOptions => sendOptions.to.address === primaryEmail)); 40 | sandbox.stub(nodemailer, 'createTransport').returns({ 41 | sendMail: sendEmailStub 42 | }); 43 | const init = require('../../src/server'); 44 | app = await init(conf); 45 | }); 46 | 47 | beforeEach(async () => { 48 | await mongo.clear(DB_TYPE_PUB_KEY); 49 | emailParams = null; 50 | }); 51 | 52 | after(async () => { 53 | sandbox.restore(); 54 | await mongo.clear(DB_TYPE_PUB_KEY); 55 | await mongo.disconnect(); 56 | await app.stop(); 57 | }); 58 | 59 | describe('REST API', () => { 60 | describe('POST /api/v1/key', () => { 61 | it('should return 400 for an invalid pgp key', done => { 62 | request(app.info.uri) 63 | .post('/api/v1/key') 64 | .send({publicKeyArmored: 'foo'}) 65 | .expect(400) 66 | .end(done); 67 | }); 68 | 69 | it('should return 200', done => { 70 | request(app.info.uri) 71 | .post('/api/v1/key') 72 | .send({publicKeyArmored}) 73 | .expect(201) 74 | .end(() => { 75 | expect(emailParams).to.exist; 76 | done(); 77 | }); 78 | }); 79 | }); 80 | 81 | describe('GET /api/v1/key?op=verify', () => { 82 | beforeEach(done => { 83 | request(app.info.uri) 84 | .post('/api/v1/key') 85 | .send({publicKeyArmored}) 86 | .expect(200) 87 | .end(done); 88 | }); 89 | 90 | it('should return 200 for valid params', done => { 91 | request(app.info.uri) 92 | .get(`/api/v1/key?op=verify&keyId=${emailParams.keyId}&nonce=${emailParams.nonce}`) 93 | .expect(200) 94 | .end(done); 95 | }); 96 | 97 | it('should return 400 for missing keyid and', done => { 98 | request(app.info.uri) 99 | .get(`/api/v1/key?op=verify&nonce=${emailParams.nonce}`) 100 | .expect(400) 101 | .end(done); 102 | }); 103 | 104 | it('should return 400 for missing nonce', done => { 105 | request(app.info.uri) 106 | .get(`/api/v1/key?op=verify&keyId=${emailParams.keyId}`) 107 | .expect(400) 108 | .end(done); 109 | }); 110 | }); 111 | 112 | describe('GET /api/key', () => { 113 | beforeEach(done => { 114 | request(app.info.uri) 115 | .post('/api/v1/key') 116 | .send({publicKeyArmored}) 117 | .expect(200) 118 | .end(done); 119 | }); 120 | 121 | describe('Not yet verified', () => { 122 | it('should return 404', done => { 123 | request(app.info.uri) 124 | .get(`/api/v1/key?keyId=${emailParams.keyId}`) 125 | .expect(404).end(done); 126 | }); 127 | }); 128 | 129 | describe('Verified', () => { 130 | beforeEach(done => { 131 | request(app.info.uri) 132 | .get(`/api/v1/key?op=verify&keyId=${emailParams.keyId}&nonce=${emailParams.nonce}`) 133 | .expect(200) 134 | .end(done); 135 | }); 136 | 137 | it('should return 200 and get key by id', done => { 138 | request(app.info.uri) 139 | .get(`/api/v1/key?keyId=${emailParams.keyId}`) 140 | .expect(200) 141 | .end(done); 142 | }); 143 | 144 | it('should return 200 and get key email address', done => { 145 | request(app.info.uri) 146 | .get(`/api/v1/key?email=${primaryEmail}`) 147 | .expect(200) 148 | .end(done); 149 | }); 150 | 151 | it('should return 400 for missing params', done => { 152 | request(app.info.uri) 153 | .get('/api/v1/key') 154 | .expect(400) 155 | .end(done); 156 | }); 157 | 158 | it('should return 400 for short key id', done => { 159 | request(app.info.uri) 160 | .get('/api/v1/key?keyId=01234567') 161 | .expect(400) 162 | .end(done); 163 | }); 164 | 165 | it('should return 404 for wrong key id', done => { 166 | request(app.info.uri) 167 | .get('/api/v1/key?keyId=0123456789ABCDEF') 168 | .expect(404) 169 | .end(done); 170 | }); 171 | }); 172 | }); 173 | 174 | describe('DELETE /api/v1/key', () => { 175 | beforeEach(done => { 176 | request(app.info.uri) 177 | .post('/api/v1/key') 178 | .send({publicKeyArmored}) 179 | .expect(200) 180 | .end(done); 181 | }); 182 | 183 | it('should return 200 for key id', done => { 184 | request(app.info.uri) 185 | .del(`/api/v1/key?keyId=${emailParams.keyId}`) 186 | .expect(200) 187 | .end(done); 188 | }); 189 | 190 | it('should return 200 for email address', done => { 191 | request(app.info.uri) 192 | .del(`/api/v1/key?email=${primaryEmail}`) 193 | .expect(200) 194 | .end(done); 195 | }); 196 | 197 | it('should return 400 for invalid params', done => { 198 | request(app.info.uri) 199 | .del('/api/v1/key') 200 | .expect(400) 201 | .end(done); 202 | }); 203 | 204 | it('should return 404 for unknown email address', done => { 205 | request(app.info.uri) 206 | .del('/api/v1/key?email=a@foo.com') 207 | .expect(404) 208 | .end(done); 209 | }); 210 | }); 211 | 212 | describe('GET /api/v1/key?op=verifyRemove', () => { 213 | beforeEach(done => { 214 | request(app.info.uri) 215 | .post('/api/v1/key') 216 | .send({publicKeyArmored}) 217 | .expect(200) 218 | .end(() => { 219 | request(app.info.uri) 220 | .del(`/api/v1/key?keyId=${emailParams.keyId}`) 221 | .expect(200) 222 | .end(done); 223 | }); 224 | }); 225 | 226 | it('should return 200 for key id', done => { 227 | request(app.info.uri) 228 | .get(`/api/v1/key?op=verifyRemove&keyId=${emailParams.keyId}&nonce=${emailParams.nonce}`) 229 | .expect(200) 230 | .end(done); 231 | }); 232 | 233 | it('should return 400 for invalid params', done => { 234 | request(app.info.uri) 235 | .get('/api/v1/key?op=verifyRemove') 236 | .expect(400) 237 | .end(done); 238 | }); 239 | 240 | it('should return 404 for unknown key id', done => { 241 | request(app.info.uri) 242 | .get(`/api/v1/key?op=verifyRemove&keyId=0123456789ABCDEF&nonce=${emailParams.nonce}`) 243 | .expect(404) 244 | .end(done); 245 | }); 246 | }); 247 | }); 248 | 249 | describe('HKP API', () => { 250 | describe('POST /pks/add', () => { 251 | it('should return 400 for an invalid body', done => { 252 | request(app.info.uri) 253 | .post('/pks/add') 254 | .type('form') 255 | .send('keytext=asdf') 256 | .expect(400) 257 | .end(done); 258 | }); 259 | 260 | it('should return 200 for a valid PGP key', done => { 261 | request(app.info.uri) 262 | .post('/pks/add') 263 | .type('form') 264 | .send(`keytext=${encodeURIComponent(publicKeyArmored)}`) 265 | .expect(200) 266 | .end(done); 267 | }); 268 | }); 269 | 270 | describe('GET /pks/lookup', () => { 271 | beforeEach(done => { 272 | request(app.info.uri) 273 | .post('/pks/add') 274 | .type('form') 275 | .send(`keytext=${encodeURIComponent(publicKeyArmored)}`) 276 | .expect(200) 277 | .end(done); 278 | }); 279 | 280 | describe('Not yet verified', () => { 281 | it('should return 404', done => { 282 | request(app.info.uri) 283 | .get(`/pks/lookup?op=get&search=0x${emailParams.keyId}`) 284 | .expect(404) 285 | .end(done); 286 | }); 287 | }); 288 | 289 | describe('Verified', () => { 290 | beforeEach(done => { 291 | request(app.info.uri) 292 | .get(`/api/v1/key?op=verify&keyId=${emailParams.keyId}&nonce=${emailParams.nonce}`) 293 | .expect(200) 294 | .end(done); 295 | }); 296 | 297 | it('should return 200 for key id', done => { 298 | request(app.info.uri) 299 | .get(`/pks/lookup?op=get&search=0x${emailParams.keyId}`) 300 | .expect(200) 301 | .end(done); 302 | }); 303 | 304 | it('should return 200 for key id without 0x prefix', done => { 305 | request(app.info.uri) 306 | .get(`/pks/lookup?op=get&search=${emailParams.keyId}`) 307 | .expect(200) 308 | .end(done); 309 | }); 310 | 311 | it('should return 200 for fingerprint', done => { 312 | request(app.info.uri) 313 | .get(`/pks/lookup?op=get&search=0x${fingerprint}`) 314 | .expect(200) 315 | .end(done); 316 | }); 317 | 318 | it('should return 200 for fingerprint without 0x prefix', done => { 319 | request(app.info.uri) 320 | .get(`/pks/lookup?op=get&search=${fingerprint}`) 321 | .expect(200) 322 | .end(done); 323 | }); 324 | 325 | it('should return 200 for fingerprint with whitespaces', done => { 326 | request(app.info.uri) 327 | .get(`/pks/lookup?op=get&search=${fingerprint.match(/.{1,4}/g).join(' ')}`) 328 | .expect(200) 329 | .end(done); 330 | }); 331 | 332 | it('should return 200 for correct email address', done => { 333 | request(app.info.uri) 334 | .get(`/pks/lookup?op=get&search=${primaryEmail}`) 335 | .expect(200) 336 | .end(done); 337 | }); 338 | 339 | it('should support email address wrapped in angle-brackets', done => { 340 | request(app.info.uri) 341 | .get(`/pks/lookup?op=get&search=<${primaryEmail}>`) 342 | .expect(200) 343 | .end(done); 344 | }); 345 | 346 | it('should return 200 for "mr" option', done => { 347 | request(app.info.uri) 348 | .get(`/pks/lookup?op=get&options=mr&search=${primaryEmail}`) 349 | .expect('Content-Type', 'application/pgp-keys; charset=utf-8') 350 | .expect('Content-Disposition', 'attachment; filename=openpgp-key.asc') 351 | .expect(200) 352 | .end(done); 353 | }); 354 | 355 | it('should return 200 for "vindex" op', done => { 356 | request(app.info.uri) 357 | .get(`/pks/lookup?op=vindex&search=0x${emailParams.keyId}`) 358 | .expect(200) 359 | .end(done); 360 | }); 361 | 362 | it('should return 200 for "index" with "mr" option', done => { 363 | request(app.info.uri) 364 | .get(`/pks/lookup?op=index&options=mr&search=0x${emailParams.keyId}`) 365 | .expect('Content-Type', 'text/plain; charset=utf-8') 366 | .expect(200) 367 | .end(done); 368 | }); 369 | 370 | it('should return 400 for invalid email', done => { 371 | request(app.info.uri) 372 | .get('/pks/lookup?op=get&search=a@bco') 373 | .expect(400) 374 | .end(done); 375 | }); 376 | 377 | it('should return 400 for search with empty angle-brackets', done => { 378 | request(app.info.uri) 379 | .get('/pks/lookup?op=get&search=<>') 380 | .expect(400) 381 | .end(done); 382 | }); 383 | 384 | it('should return 404 for unkown email', done => { 385 | request(app.info.uri) 386 | .get('/pks/lookup?op=get&search=a@b.co') 387 | .expect(404) 388 | .end(done); 389 | }); 390 | 391 | it('should return 400 for missing params', done => { 392 | request(app.info.uri) 393 | .get('/pks/lookup?op=get') 394 | .expect(400) 395 | .end(done); 396 | }); 397 | 398 | it('should return 404 for unkown key id', done => { 399 | request(app.info.uri) 400 | .get('/pks/lookup?op=get&search=0xDBC0B3D92A1B86E9') 401 | .expect(404) 402 | .end(done); 403 | }); 404 | 405 | it('should return 400 for short key id', done => { 406 | request(app.info.uri) 407 | .get('/pks/lookup?op=get&search=0x2A1B86E9') 408 | .expect(400) 409 | .end(done); 410 | }); 411 | 412 | it('should return 400 for a short key id without 0x prefix', done => { 413 | request(app.info.uri) 414 | .get('/pks/lookup?op=get&search=2a1b86e9') 415 | .expect(400) 416 | .end(done); 417 | }); 418 | 419 | it('should return 501 (Not implemented) for "x-email" op', done => { 420 | request(app.info.uri) 421 | .get(`/pks/lookup?op=x-email&search=0x${emailParams.keyId}`) 422 | .expect(501) 423 | .end(done); 424 | }); 425 | }); 426 | }); 427 | }); 428 | }); 429 | -------------------------------------------------------------------------------- /test/unit/pgp-test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const log = require('../../src/lib/log'); 5 | const config = require('../../config/config'); 6 | const openpgp = require('openpgp'); 7 | const PGP = require('../../src/modules/pgp'); 8 | const PurifyKey = require('../../src/modules/purify-key'); 9 | const {KEY_STATUS} = require('../../src/lib/util'); 10 | 11 | describe('PGP Unit Tests', () => { 12 | const sandbox = sinon.createSandbox(); 13 | let pgp; 14 | let purify; 15 | let key1Armored; 16 | let key2Armored; 17 | let key3Armored; 18 | let key5Armored; 19 | let key6Armored; 20 | 21 | before(() => { 22 | key1Armored = fs.readFileSync(`${__dirname}/../fixtures/key1.asc`, 'utf8'); 23 | key2Armored = fs.readFileSync(`${__dirname}/../fixtures/key2.asc`, 'utf8'); 24 | key3Armored = fs.readFileSync(`${__dirname}/../fixtures/key3.asc`, 'utf8'); 25 | key5Armored = fs.readFileSync(`${__dirname}/../fixtures/key5.asc`, 'utf8'); 26 | key6Armored = fs.readFileSync(`${__dirname}/../fixtures/key6.asc`, 'utf8'); 27 | }); 28 | 29 | beforeEach(() => { 30 | sandbox.stub(log); 31 | purify = new PurifyKey(config.purify); 32 | pgp = new PGP(purify); 33 | }); 34 | 35 | afterEach(() => { 36 | sandbox.restore(); 37 | }); 38 | 39 | describe('parseKey', () => { 40 | it('should throw error on failed key parsing', async () => { 41 | sandbox.stub(openpgp, 'readKey').throws(new Error('test error')); 42 | await expect(pgp.parseKey(key3Armored)).to.eventually.be.rejectedWith(/Failed to read PGP key: test error/); 43 | expect(log.error.calledOnce).to.be.true; 44 | }); 45 | 46 | it('should throw error when verifyPrimaryKey throws', () => { 47 | sandbox.stub(openpgp, 'readKey').returns({ 48 | isPrivate() { return false; }, 49 | armor() { return 'ABC'; }, 50 | verifyPrimaryKey() { throw new Error('Invalid primary key'); } 51 | }); 52 | pgp.purify.conf.purifyKey = false; 53 | return expect(pgp.parseKey(key3Armored)).to.eventually.be.rejectedWith('Invalid PGP key. Verification of the primary key failed.'); 54 | }); 55 | 56 | it('should not throw if key is revoked', async () => { 57 | const key = await pgp.parseKey(key6Armored); 58 | expect(key).to.exist; 59 | }); 60 | 61 | it('should refuse private keys', () => { 62 | sandbox.stub(openpgp, 'readKey').returns({ 63 | isPrivate() { return true; }, 64 | armor() { return 'ABC'; } 65 | }); 66 | return expect(pgp.parseKey(key3Armored)).to.eventually.be.rejectedWith(/Error uploading private key/); 67 | }); 68 | 69 | it('should only accept valid user ids', () => { 70 | sandbox.stub(pgp, 'parseUserIds').returns([]); 71 | return expect(pgp.parseKey(key3Armored)).to.eventually.be.rejectedWith(/Invalid PGP key: no valid user ID with email address found/); 72 | }); 73 | 74 | it('should be able to parse RSA key', async () => { 75 | const params = await pgp.parseKey(key1Armored); 76 | expect(params.keyId).to.equal('dbc0b3d92b1b86e9'); 77 | expect(params.fingerprint).to.equal('4277257930867231ce393fb8dbc0b3d92b1b86e9'); 78 | expect(params.userIds[0].name).to.equal('safewithme testuser'); 79 | expect(params.userIds[0].email).to.equal('safewithme.testuser@gmail.com'); 80 | expect(params.created.getTime()).to.exist; 81 | expect(params.uploaded.getTime()).to.exist; 82 | expect(params.algorithm).to.equal('rsaEncryptSign'); 83 | expect(params.keySize).to.equal(2048); 84 | expect(params.publicKeyArmored).to.include('PGP PUBLIC KEY'); 85 | }); 86 | 87 | it('should be able to parse ECC key', async () => { 88 | const params = await pgp.parseKey(key2Armored); 89 | expect(params.keyId).to.equal('4c03a47362c5b4cc'); 90 | expect(params.fingerprint).to.equal('90507fb229658f71f3de96a84c03a47362c5b4cc'); 91 | expect(params.userIds.length).to.equal(1); 92 | expect(params.created.getTime()).to.exist; 93 | expect(params.uploaded.getTime()).to.exist; 94 | expect(params.algorithm).to.equal('eddsa'); 95 | expect(params.publicKeyArmored).to.include('PGP PUBLIC KEY'); 96 | }); 97 | 98 | it('should be able to parse komplex key', async () => { 99 | const params = await pgp.parseKey(key3Armored); 100 | expect(params.keyId).to.equal('4001a127a90de8e1'); 101 | expect(params.fingerprint).to.equal('04062c70b446e33016e219a74001a127a90de8e1'); 102 | expect(params.userIds.length).to.equal(4); 103 | expect(params.created.getTime()).to.exist; 104 | expect(params.uploaded.getTime()).to.exist; 105 | expect(params.algorithm).to.equal('rsaEncryptSign'); 106 | expect(params.keySize).to.equal(4096); 107 | expect(params.publicKeyArmored).to.include('PGP PUBLIC KEY'); 108 | }); 109 | 110 | it('should be able to parse key with user ID with ,', async () => { 111 | const {publicKey} = await openpgp.generateKey({ 112 | userIDs: [{name: 'Demo, Mailvelope', email: 'demo@mailvelope.com'}], 113 | passphrase: '1234' 114 | }); 115 | const params = await pgp.parseKey(publicKey); 116 | expect(params.userIds).to.have.lengthOf(1); 117 | expect(params.userIds[0].name).to.equal('Demo, Mailvelope'); 118 | expect(params.userIds[0].email).to.equal('demo@mailvelope.com'); 119 | }); 120 | }); 121 | 122 | describe('verifyKey', () => { 123 | it('should verify valid key', async () => { 124 | const key = await openpgp.readKey({armoredKey: key2Armored}); 125 | const status = await pgp.verifyKey(key); 126 | expect(status).to.equal(KEY_STATUS.valid); 127 | }); 128 | 129 | it('should verify invalid key', async () => { 130 | const key = await openpgp.readKey({armoredKey: key2Armored}); 131 | key.users[0].selfCertifications[0].signedHashValue[0] = 1; 132 | const status = await pgp.verifyKey(key); 133 | expect(status).to.equal(KEY_STATUS.invalid); 134 | }); 135 | 136 | it('should verify revoked key', async () => { 137 | const key = await openpgp.readKey({armoredKey: key6Armored}); 138 | const status = await pgp.verifyKey(key); 139 | expect(status).to.equal(KEY_STATUS.revoked); 140 | }); 141 | 142 | it('should verify expired key', async () => { 143 | const key = await openpgp.readKey({armoredKey: key2Armored}); 144 | key.users[0].selfCertifications[0].keyExpirationTime = 1; 145 | const status = await pgp.verifyKey(key); 146 | expect(status).to.equal(KEY_STATUS.expired); 147 | }); 148 | 149 | it('should verify key without user self certification but with key revocation', async () => { 150 | const key = await openpgp.readKey({armoredKey: key6Armored}); 151 | key.users.length = 1; 152 | key.users[0].selfCertifications = []; 153 | expect(key.revocationSignatures).to.have.lengthOf(1); 154 | expect(key.users[0].revocationSignatures).to.have.lengthOf(0); 155 | const status = await pgp.verifyKey(key); 156 | expect(status).to.equal(KEY_STATUS.revoked); 157 | }); 158 | 159 | it('should verify key without users but with key revocation', async () => { 160 | const key = await openpgp.readKey({armoredKey: key6Armored}); 161 | key.users.length = 0; 162 | expect(key.revocationSignatures).to.have.lengthOf(1); 163 | const status = await pgp.verifyKey(key); 164 | expect(status).to.equal(KEY_STATUS.revoked); 165 | }); 166 | 167 | it('should verify unrevoked key without users', async () => { 168 | const key = await openpgp.readKey({armoredKey: key2Armored}); 169 | key.users.length = 0; 170 | expect(key.revocationSignatures).to.have.lengthOf(0); 171 | const status = await pgp.verifyKey(key); 172 | expect(status).to.equal(KEY_STATUS.invalid); 173 | }); 174 | 175 | it('should verify unrevoked key with user self certification and with user revocation', async () => { 176 | const key = await openpgp.readKey({armoredKey: key6Armored}); 177 | const user = key.users[4]; 178 | key.users = [user]; 179 | key.revocationSignatures = []; 180 | expect(key.users[0].selfCertifications).to.have.lengthOf(1); 181 | const status = await pgp.verifyKey(key); 182 | expect(status).to.equal(KEY_STATUS.revoked); 183 | }); 184 | 185 | it.skip('should verify unrevoked key without user self certification but with user revocation', async () => { 186 | const key = await openpgp.readKey({armoredKey: key6Armored}); 187 | const user = key.users[4]; 188 | key.users = [user]; 189 | key.users[0].selfCertifications = []; 190 | key.revocationSignatures = []; 191 | expect(key.users[0].revocationSignatures).to.have.lengthOf(4); 192 | const status = await pgp.verifyKey(key); 193 | expect(status).to.equal(KEY_STATUS.revoked); 194 | }); 195 | 196 | it('should verify unrevoked key with user but without any user certificates', async () => { 197 | const key = await openpgp.readKey({armoredKey: key6Armored}); 198 | const user = key.users[4]; 199 | key.users = [user]; 200 | key.users[0].selfCertifications = []; 201 | key.users[0].revocationSignatures = []; 202 | key.revocationSignatures = []; 203 | const status = await pgp.verifyKey(key); 204 | expect(status).to.equal(KEY_STATUS.invalid); 205 | }); 206 | }); 207 | 208 | describe('parseUserIds', () => { 209 | let key; 210 | 211 | beforeEach(async () => { 212 | key = await openpgp.readKey({armoredKey: key1Armored}); 213 | }); 214 | 215 | it('should parse a valid user id', async () => { 216 | const parsed = await pgp.parseUserIds(key); 217 | expect(parsed[0].name).to.equal('safewithme testuser'); 218 | expect(parsed[0].email).to.equal('safewithme.testuser@gmail.com'); 219 | }); 220 | 221 | it('should return no user id for an invalid signature', async () => { 222 | key.users[0].userID.userID = 'fake@example.com'; 223 | const parsed = await pgp.parseUserIds(key); 224 | expect(parsed.length).to.equal(0); 225 | }); 226 | 227 | it('should return no user id if no email address', async () => { 228 | key.users[0].userID.email = ''; 229 | expect(key.users[0].userID.name).to.exist; 230 | const parsed = await pgp.parseUserIds(key); 231 | expect(parsed.length).to.equal(0); 232 | }); 233 | 234 | it('should re-parse user ID if no email address and no name', async () => { 235 | key.users[0].userID.email = ''; 236 | key.users[0].userID.name = ''; 237 | const parsed = await pgp.parseUserIds(key); 238 | expect(parsed[0].name).to.equal('safewithme testuser'); 239 | expect(parsed[0].email).to.equal('safewithme.testuser@gmail.com'); 240 | }); 241 | }); 242 | 243 | describe('verifyUser', () => { 244 | it('should verify valid user', async () => { 245 | const key = await openpgp.readKey({armoredKey: key2Armored}); 246 | const keyStatus = await pgp.verifyUser(key.users[0]); 247 | expect(keyStatus).to.equal(KEY_STATUS.valid); 248 | }); 249 | 250 | it('should verify invalid user', async () => { 251 | const key = await openpgp.readKey({armoredKey: key2Armored}); 252 | key.users[0].selfCertifications[0].signedHashValue[0] = 1; 253 | const keyStatus = await pgp.verifyUser(key.users[0]); 254 | expect(keyStatus).to.equal(KEY_STATUS.invalid); 255 | }); 256 | 257 | it('should verify revoked user', async () => { 258 | const key = await openpgp.readKey({armoredKey: key6Armored}); 259 | const keyStatus = await pgp.verifyUser(key.users[4]); 260 | expect(keyStatus).to.equal(KEY_STATUS.revoked); 261 | }); 262 | 263 | it('should verify revoked user without self certification', async () => { 264 | const key = await openpgp.readKey({armoredKey: key6Armored}); 265 | key.users[4].selfCertifications = []; 266 | const keyStatus = await pgp.verifyUser(key.users[4]); 267 | expect(keyStatus).to.equal(KEY_STATUS.revoked); 268 | }); 269 | 270 | it('should verify user without self certification', async () => { 271 | const key = await openpgp.readKey({armoredKey: key2Armored}); 272 | key.users[0].selfCertifications = []; 273 | const keyStatus = await pgp.verifyUser(key.users[0]); 274 | expect(keyStatus).to.equal(KEY_STATUS.no_self_cert); 275 | }); 276 | 277 | it('should verify expired user', async () => { 278 | const key = await openpgp.readKey({armoredKey: key2Armored}); 279 | key.users[0].selfCertifications[0].signatureNeverExpires = null; 280 | key.users[0].selfCertifications[0].signatureExpirationTime = 1; 281 | const keyStatus = await pgp.verifyUser(key.users[0]); 282 | expect(keyStatus).to.equal(KEY_STATUS.expired); 283 | }); 284 | }); 285 | 286 | describe('filterKeyByUserIds', () => { 287 | it('should filter user IDs', async () => { 288 | const email = 'test1@example.com'; 289 | const key = await openpgp.readKey({armoredKey: key3Armored}); 290 | expect(key.users.length).to.equal(4); 291 | const filtered = await pgp.filterKeyByUserIds([{email}], key3Armored); 292 | const filteredKey = await openpgp.readKey({armoredKey: filtered}); 293 | expect(filteredKey.users.length).to.equal(1); 294 | expect(filteredKey.users[0].userID.email).to.equal(email); 295 | }); 296 | 297 | it('should filter user attributes', async () => { 298 | const email = 'test@example.com'; 299 | const key = await openpgp.readKey({armoredKey: key5Armored}); 300 | expect(key.users.length).to.equal(2); 301 | const filtered = await pgp.filterKeyByUserIds([{email}], key5Armored); 302 | const filteredKey = await openpgp.readKey({armoredKey: filtered}); 303 | expect(filteredKey.users.length).to.equal(1); 304 | expect(filteredKey.users[0].userID).to.exist; 305 | }); 306 | 307 | it('should throw if no valid encryption key', () => expect(pgp.filterKeyByUserIds([{email: 'demo@mailvelope.com'}], key6Armored, true)).to.eventually.be.rejectedWith('Invalid PGP key. No valid encryption key found')); 308 | }); 309 | 310 | describe('removeUserId', () => { 311 | it('should remove user IDs', async () => { 312 | const email = 'test1@example.com'; 313 | const key = await openpgp.readKey({armoredKey: key3Armored}); 314 | expect(key.users.length).to.equal(4); 315 | const reduced = await pgp.removeUserId(email, key3Armored); 316 | const reducedKey = await openpgp.readKey({armoredKey: reduced}); 317 | expect(reducedKey.users.length).to.equal(3); 318 | expect(reducedKey.users.find(({userID}) => userID.email === email)).to.be.undefined; 319 | }); 320 | 321 | it('should not remove user attributes', async () => { 322 | const email = 'test@example.com'; 323 | const key = await openpgp.readKey({armoredKey: key5Armored}); 324 | expect(key.users.length).to.equal(2); 325 | const reduced = await pgp.removeUserId(email, key5Armored); 326 | const reducedKey = await openpgp.readKey({armoredKey: reduced}); 327 | expect(reducedKey.users.length).to.equal(1); 328 | expect(reducedKey.users[0].userAttribute).to.exist; 329 | }); 330 | }); 331 | }); 332 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Mailvelope Keyserver 2 | ==================== 3 | 4 | A simple OpenPGP public key server that validates email address ownership of uploaded keys. 5 | 6 | ## Why not use Web of Trust? 7 | 8 | There are already OpenPGP key servers like the [SKS keyserver](https://github.com/SKS-Keyserver/sks-keyserver) that employ the [Web of Trust](https://en.wikipedia.org/wiki/Web_of_trust) to provide a way to authenticate a user's PGP keys. The problem with these servers are discussed [here](https://en.wikipedia.org/wiki/Key_server_(cryptographic)#Problems_with_keyservers). 9 | 10 | ### Privacy 11 | 12 | The web of trust raises some valid privacy concerns. Not only is a user's social network made public, common SKS servers are also not compliant with the [EU Data Protection Directive](https://en.wikipedia.org/wiki/Data_Protection_Directive) due to lack of key deletion. This key server addresses these issues by not employing the web of trust and by allowing key removal. 13 | 14 | ### Usability 15 | 16 | The main issue with the Web of Trust though is that it does not scale in terms of usability. The goal of this key server is to enable a better user experience for OpenPGP user agents by providing a more reliable source of public keys. Similar to messengers like Signal, users verify their email address by clicking on a link of a PGP encrypted message. This prevents user A from uploading a public key for user B. With this property in place, automatic key lookup is more reliable than with standard SKS servers. 17 | 18 | This requires more trust to be placed in the service provider that hosts a key server, but we believe that this trade-off is necessary to improve the user experience for average users. Tech-savvy users or users with a threat model that requires stronger security may still choose to verify PGP key fingerprints just as before. 19 | 20 | ## Standardization and (De)centralization 21 | 22 | The idea is that an identity provider such as an email provider can host their own key directory under a common `openpgpkey` subdomain. An OpenPGP supporting user agent should attempt to lookup keys under the user's domain e.g. `https://openpgpkey.example.com` for `user@example.com` first. User agents can host their own fallback key server as well, in case a mail provider does not provide its own key directory. 23 | 24 | # Demo 25 | 26 | Try out the server here: [https://keys.mailvelope.com](https://keys.mailvelope.com) 27 | 28 | # API 29 | 30 | The key server provides a modern RESTful API, but is also backwards compatible to the OpenPGP HTTP Keyserver Protocol (HKP). The following properties are enforced by the key server to enable reliable automatic key look in user agents: 31 | 32 | * Only public keys with at least one verified email address are served 33 | * There can be only one public key per verified email address at a given time 34 | * A key ID specified in a query must be at least 16 hex characters (64-bit long key ID) 35 | * Key ID collisions are checked upon key upload to prevent collision attacks 36 | 37 | ## HKP API 38 | 39 | The HKP APIs are not documented here. Please refer to the [HKP specification](https://tools.ietf.org/html/draft-shaw-openpgp-hkp-00) to learn more. The server generally implements the full specification, but has some constraints to improve the security for automatic key lookup: 40 | 41 | #### Accepted `search` parameters 42 | * Email addresses 43 | * V4 Fingerprints 44 | * Key IDs with 16 digits (64-bit long key ID) 45 | 46 | #### Accepted `op` parameters 47 | * get 48 | * index 49 | * vindex 50 | 51 | #### Accepted `options` parameters 52 | * mr 53 | 54 | #### Usage example with GnuPG 55 | 56 | ``` 57 | gpg --keyserver hkps://keys.mailvelope.com --search info@mailvelope.com 58 | ``` 59 | 60 | ## REST API 61 | 62 | ### Lookup a key 63 | 64 | #### By key ID 65 | 66 | ``` 67 | GET /api/v1/key?keyId=b8e4105cc9dedc77 68 | ``` 69 | 70 | #### By fingerprint 71 | 72 | ``` 73 | GET /api/v1/key?fingerprint=e3317db04d3958fd5f662c37b8e4105cc9dedc77 74 | ``` 75 | 76 | #### By email address 77 | 78 | ``` 79 | GET /api/v1/key?email=user@example.com 80 | ``` 81 | 82 | #### Payload (JSON): 83 | 84 | ```json 85 | { 86 | "keyId": "b8e4105cc9dedc77", 87 | "fingerprint": "e3317db04d3958fd5f662c37b8e4105cc9dedc77", 88 | "userIds": [ 89 | { 90 | "name": "Jon Smith", 91 | "email": "jon@smith.com", 92 | "verified": "true" 93 | }, 94 | { 95 | "name": "Jon Smith", 96 | "email": "jon@organization.com", 97 | "verified": "false" 98 | } 99 | ], 100 | "created": "Sat Oct 17 2015 12:17:03 GMT+0200 (CEST)", 101 | "algorithm": "rsaEncryptSign", 102 | "keySize": "4096", 103 | "publicKeyArmored": "-----BEGIN PGP PUBLIC KEY BLOCK----- ... -----END PGP PUBLIC KEY BLOCK-----" 104 | } 105 | ``` 106 | 107 | * **keyId**: The 16 char key id in hex 108 | * **fingerprint**: The 40 char key fingerprint in hex 109 | * **userIds.name**: The user ID's name 110 | * **userIds.email**: The user ID's email address 111 | * **userIds.verified**: If the user ID's email address has been verified 112 | * **created**: The key creation time as a JavaScript Date 113 | * **algorithm**: The primary key alogrithm 114 | * **keySize**: The key length in bits 115 | * **publicKeyArmored**: The ascii armored public key block 116 | 117 | ### Upload new key 118 | 119 | ``` 120 | POST /api/v1/key 121 | ``` 122 | 123 | #### Payload (JSON): 124 | 125 | ```json 126 | { 127 | "publicKeyArmored": "-----BEGIN PGP PUBLIC KEY BLOCK----- ... -----END PGP PUBLIC KEY BLOCK-----" 128 | } 129 | ``` 130 | 131 | * **publicKeyArmored**: The ascii armored public PGP key to be uploaded 132 | 133 | E.g. to upload a key from shell: 134 | ```bash 135 | curl https://keys.mailvelope.com/api/v1/key --data "{\"publicKeyArmored\":\"$( \ 136 | gpg --armor --export-options export-minimal --export $GPGKEYID | sed ':a;N;$!ba;s/\n/\\n/g' \ 137 | )\"}" 138 | ``` 139 | 140 | ### Verify uploaded key (via link in email) 141 | 142 | ``` 143 | GET /api/v1/key?op=verify&keyId=b8e4105cc9dedc77&nonce=6a314915c09368224b11df0feedbc53c 144 | ``` 145 | 146 | ### Request key removal 147 | 148 | ``` 149 | DELETE /api/v1/key?keyId=b8e4105cc9dedc77 OR ?email=user@example.com 150 | ``` 151 | 152 | ### Verify key removal (via link in email) 153 | 154 | ``` 155 | GET /api/v1/key?op=verifyRemove&keyId=b8e4105cc9dedc77&nonce=6a314915c09368224b11df0feedbc53c 156 | ``` 157 | 158 | ## Abuse resistant key server 159 | 160 | The key server implements mechanisms described in the draft [Abuse-Resistant OpenPGP Keystores](https://datatracker.ietf.org/doc/html/draft-dkg-openpgp-abuse-resistant-keystore-06) to mitigate various attacks related to flooding the key server with bogus keys or certificates. The filtering of keys can be customized with [environment variables](#settings). 161 | 162 | In detail the following key components are filtered out: 163 | 164 | * user attribute packets 165 | * third-party certificates 166 | * certificates exceeding 8383 bytes 167 | * certificates that cannot be verified with primary key 168 | * unhashed subpackets except: issuer, issuerFingerprint, embeddedSignature 169 | * unhashed subpackets of embedded signatures 170 | * user IDs without email address 171 | * user IDs exceeding 1024 bytes 172 | * user IDs that have no self certificate or revocation signature 173 | * subkeys exceeding 8383 bytes 174 | * above 5 revocation signatures. Hardest, earliest revocations are kept. 175 | * superseded certificates. Newest 5 are kept. 176 | 177 | A key is rejected if one of the following is detected: 178 | 179 | * primary key packet exceeding 8383 bytes 180 | * primary key packet is not version 4 181 | * key without user ID 182 | * key with more than 20 email addresses 183 | * key with more than 20 subkeys 184 | * key size exceeding 32768 bytes 185 | * new uploaded key is not valid 24h in the future 186 | 187 | # Language & DB 188 | 189 | The server is written is in JavaScript ES2020 and runs on [Node.js](https://nodejs.org/) v18+. 190 | 191 | It uses [MongoDB](https://www.mongodb.com/) v6.0+ as its database. 192 | 193 | Note: You may also use [FerretDB](https://ferretdb.com) 2.0+, which aims to provide a Free Software replacement for MongoDB. But you will need to use [an older version of Mailvelope Key Server](https://github.com/mailvelope/keyserver/tree/c7ffbefa744473be06500e722dc1c9327a130cd5) since FerretDB [TTL indexes are not fully MongoDB compatible](https://github.com/FerretDB/FerretDB/issues/4960) yet. 194 | 195 | # Getting started 196 | ## Installation 197 | 198 | ### Node.js (macOS) 199 | 200 | This is how to install node on Mac OS using [homebrew](https://brew.sh/). For other operating systems, please refer to the [Node.js download page](https://nodejs.org/en/download/). 201 | 202 | ```shell 203 | brew update 204 | brew install node 205 | ``` 206 | 207 | ### MongoDB (macOS) 208 | 209 | This is the installation guide to get a local development installation on macOS using [homebrew](https://brew.sh/). For other operating systems, please refer to the [MongoDB Installation Tutorials](https://www.mongodb.com/docs/v6.0/installation/#mongodb-installation-tutorials). 210 | 211 | ```shell 212 | brew update 213 | brew install mongodb-community@6.0 214 | mongod --config /opt/homebrew/etc/mongod.conf 215 | ``` 216 | 217 | Now the mongo daemon should be running in the background. To have mongo start automatically as a background service on startup you can also do: 218 | 219 | ```shell 220 | brew services start mongodb 221 | ``` 222 | 223 | Now you can use the `mongosh` CLI client to create a new test database. The username and password used here match the ones in the `.env` file. **Be sure to change them for production use**: 224 | 225 | ```shell 226 | mongosh 227 | use keyserver-test 228 | db.createUser({ user:"keyserver-user", pwd:"your_mongo_db_pwd", roles:[{ role:"readWrite", db:"keyserver-test" }] }) 229 | ``` 230 | 231 | #### Purge unverfied keys with TTL (time to live) indexes 232 | 233 | Unverified keys are automatically purged after `PUBLIC_KEY_PURGE_TIME` days. The MongoDB TTLMonitor thread that is used for this purpose, runs by default every 60 seconds. To change this interval to a more appropriate value run the following admin command in the mongo shell: 234 | 235 | ``` 236 | db.adminCommand({setParameter:1, ttlMonitorSleepSecs: 86400}) // 1 day 237 | ``` 238 | 239 | #### Recommended indexes 240 | 241 | To improve query performance the following indexes are recommended: 242 | 243 | ``` 244 | db.publickey.createIndex({"userIds.email" : 1, "userIds.verified" : 1}) // query by email 245 | db.publickey.createIndex({"keyId" : 1, "userIds.verified" : 1}) // query by keyID 246 | db.publickey.createIndex({"fingerprint" : 1, "userIds.verified" : 1}) // query by fingerprint 247 | ``` 248 | 249 | ### Dependencies 250 | 251 | ```shell 252 | npm install 253 | ``` 254 | 255 | ## Configuration 256 | 257 | Configuration settings may be provided as environment variables. The file config/config.js reads the environment variables and defines configuration values for settings with no corresponding environment variable. Warning: Default settings are only provided for a small minority of settings in these files (as most of them are very individual like host/user/password)! 258 | 259 | ### Development 260 | 261 | If you don't use environment variables to configure settings, you can alternatively create a .env file for example with the following content: 262 | 263 | ``` 264 | PORT=3000 265 | CORS_HEADER=true 266 | HTTP_SECURITY_HEADER=true 267 | CSP_HEADER=true 268 | LOG_LEVEL=info 269 | MONGO_URI=127.0.0.1:27017/keyserver-test 270 | MONGO_USER=keyserver-user 271 | MONGO_PASS=your_mongo_db_pwd 272 | SMTP_HOST=sabic.uberspace.de 273 | SMTP_PORT=465 274 | SMTP_TLS=true 275 | SMTP_STARTTLS=false 276 | SMTP_PGP=true 277 | SMTP_USER=info@your-key-server.net 278 | SMTP_PASS=your_smtp_pwd 279 | SENDER_NAME=My Key Server Demo 280 | SENDER_EMAIL=info@your-key-server.net 281 | ``` 282 | 283 | ## Unit and integration tests 284 | 285 | Create a test database for the integration tests: 286 | 287 | ```shell 288 | mongosh 289 | use keyserver-test-int 290 | db.createUser({ user:"keyserver-user", pwd:"your_mongo_db_pwd", roles:[{ role:"readWrite", db:"keyserver-test-int" }] }) 291 | ``` 292 | 293 | Afterwards start the unit tests with `npm test`. 294 | 295 | ### Production 296 | 297 | For production use, settings configuration with environment variables is recommended as `NODE_ENV=production` is REQUIRED to be set as environment variable to instruct node.js to adapt e.g. logging to production use. 298 | 299 | ### Settings 300 | 301 | Available settings with its environment-variable-names, possible/example values and meaning (if not self-explainable). Defaults **bold**: 302 | 303 | * NODE_ENV=development|production (no default, needs to be set as environment variable) 304 | * LOG_LEVEL=debug|**info**|notice|warning|err|crit|alert|emerg 305 | * SERVER_HOST=**localhost** 306 | * PORT=**8888** (application server port) 307 | * CORS_HEADER=true [CORS headers](https://hapi.dev/api#-routeoptionscors) 308 | * HTTP_SECURITY_HEADER=true [security headers](https://hapi.dev/api#-routeoptionssecurity) 309 | * CSP_HEADER=true (add Content-Security-Policy as in src/lib/csp.js) 310 | * MONGO_URI=127.0.0.1:27017/keyserver 311 | * MONGO_USER=keyserver-user 312 | * MONGO_PASS=your_mongo_db_pwd 313 | * SMTP_HOST=smpt.your-email-provider.com 314 | * SMTP_PORT=465 315 | * SMTP_TLS=**true** (if true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false.) 316 | * SMTP_STARTTLS=**true** (if this is true and SMTP_TLS is false then Nodemailer tries to use STARTTLS even if the server does not advertise support for it.) 317 | * SMTP_PGP=**true** (encrypt verification message with public key (allows to verify presence + usability of private key at owner of the email address)) 318 | * SMTP_USER=smtp_user 319 | * SMTP_PASS=smtp_pass 320 | * SENDER_NAME="OpenPGP Key Server" 321 | * SENDER_EMAIL=noreply@your-key-server.net 322 | * PUBLIC_KEY_PURGE_TIME=**14** (number of days after which uploaded keys are deleted if they have not been verified) 323 | * UPLOAD_RATE_LIMIT=10 (key upload rate limit per email address in the PUBLIC_KEY_PURGE_TIME period) 324 | 325 | The following variables are available to customize the filtering behavior as outlined in [Abuse resistant key server](#abuse-resistant-key-server): 326 | 327 | * PURIFY_KEY=**true** (main switch to enable filtering of keys) 328 | * MAX_NUM_USER_EMAIL=**20** (max. number of email addresses per key) 329 | * MAX_NUM_SUBKEY=**20** (max. number of subkeys per key) 330 | * MAX_NUM_CERT=**5** (max. number of superseding certificates) 331 | * MAX_SIZE_USERID=**1024** 332 | * MAX_SIZE_PACKET=**8383** 333 | * MAX_SIZE_KEY=**32768** 334 | 335 | ### Notes on SMTP 336 | 337 | The key server uses [nodemailer](https://nodemailer.com) to send out emails upon public key upload to verify email address ownership. To test this feature locally, configure `SMTP_USER` and `SMTP_PASS` settings to your email test account. Make sure that `SMTP_USER` and `SENDER_EMAIL` match. 338 | 339 | For production you should use a service like [Amazon SES](https://aws.amazon.com/ses/), [Mailgun](https://www.mailgun.com/) or [Sendgrid](https://sendgrid.com/use-cases/transactional-email/). Nodemailer supports all of these out of the box. 340 | 341 | ## Run tests 342 | 343 | ```shell 344 | npm test 345 | ``` 346 | 347 | ## Start local server 348 | 349 | ```shell 350 | npm start 351 | ``` 352 | 353 | # License 354 | 355 | AGPL v3.0 356 | 357 | See the [LICENSE](https://raw.githubusercontent.com/mailvelope/keyserver/master/LICENSE) file for details 358 | 359 | ## Libraries 360 | 361 | Among others, this project relies on the following open source libraries: 362 | 363 | * [OpenPGP.js](https://openpgpjs.org/) 364 | * [Nodemailer](https://nodemailer.com/) 365 | * [hapi](https://hapi.dev/) 366 | * [mongodb](https://mongodb.github.io/node-mongodb-native/) 367 | -------------------------------------------------------------------------------- /test/fixtures/key3.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | 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 101 | mx98yWe8snW9/F5EEibzFR7kUyPLEk2w7MHY/ck= 102 | =ywUA 103 | -----END PGP PUBLIC KEY BLOCK----- 104 | -----BEGIN PGP PRIVATE KEY BLOCK----- 105 | 106 | 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 258 | RBIm8xUe5FMjyxJNsOzB2P3J 259 | =ePU4 260 | -----END PGP PRIVATE KEY BLOCK----- 261 | -------------------------------------------------------------------------------- /src/modules/public-key.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (C) 2020 Mailvelope GmbH 3 | * Licensed under the GNU Affero General Public License version 3 4 | */ 5 | 6 | 'use strict'; 7 | 8 | const Boom = require('@hapi/boom'); 9 | const config = require('../../config/config'); 10 | const util = require('../lib/util'); 11 | const tpl = require('../lib/templates'); 12 | const log = require('../lib/log'); 13 | 14 | /** 15 | * Database documents have the format: 16 | * { 17 | * _id: ObjectId, // a randomly generated MongoDB document ID 18 | * keyId: 'b8e4105cc9dedc77', // the 16 char key ID in lowercase hex 19 | * fingerprint: 'e3317db04d3958fd5f662c37b8e4105cc9dedc77', // the 40 char key fingerprint in lowercase hex 20 | * userIds: [ 21 | * { 22 | * name:'Jon Smith', 23 | * email:'jon@smith.com', 24 | * nonce: "6a314915c09368224b11df0feedbc53c", // random 32 char verifier used to prove ownership 25 | * verified: true // if the user ID has been verified 26 | * } 27 | * ], 28 | * created: Sat Oct 17 2015 12:17:03 GMT+0200 (CEST), // key creation time as JavaScript Date 29 | * uploaded: Sat Oct 17 2015 12:17:03 GMT+0200 (CEST), // time of key upload as JavaScript Date 30 | * algorithm: 'rsa_encrypt_sign', // primary key alogrithm 31 | * keySize: 4096, // key length in bits 32 | * publicKeyArmored: '-----BEGIN PGP PUBLIC KEY BLOCK----- ... -----END PGP PUBLIC KEY BLOCK-----', 33 | * verifyUntil: Mon Nov 16 2015 12:17:03 GMT+0200 (CEST) // verification deadline 34 | * } 35 | */ 36 | const DB_TYPE = 'publickey'; 37 | const {KEY_STATUS} = util; 38 | 39 | /** 40 | * A service that handlers PGP public keys queries to the database 41 | */ 42 | class PublicKey { 43 | /** 44 | * Create an instance of the service 45 | * @param {Object} pgp An instance of the OpenPGP.js wrapper 46 | * @param {Object} mongo An instance of the MongoDB client 47 | * @param {Object} email An instance of the Email Sender 48 | */ 49 | constructor(pgp, mongo, email) { 50 | this._pgp = pgp; 51 | this._mongo = mongo; 52 | this._email = email; 53 | } 54 | 55 | async init() { 56 | // create time to live (TTL) index to purge unverified keys 57 | await this._mongo.createIndexes([{key: {verifyUntil: 1}, expireAfterSeconds: 1}], DB_TYPE); 58 | } 59 | 60 | /** 61 | * Persist a new public key 62 | * @param {Array} emails (optional) The emails to upload/update 63 | * @param {String} publicKeyArmored The ascii armored pgp key block 64 | * @param {Object} origin Required for links to the keyserver e.g. { protocol:'https', host:'openpgpkeys@example.com' } 65 | * @param {Object} i18n i18n object 66 | * @return {Promise} 67 | */ 68 | async put({emails = [], publicKeyArmored, origin, i18n}) { 69 | emails = emails.map(util.normalizeEmail); 70 | // parse key block 71 | const key = await this._pgp.parseKey(publicKeyArmored); 72 | // if emails array is empty, all userIds of the key will be submitted 73 | if (emails.length) { 74 | // keep submitted user IDs only 75 | key.userIds = key.userIds.filter(({email}) => emails.includes(email)); 76 | if (key.userIds.length !== emails.length) { 77 | throw Boom.badRequest('Provided email address does not match a valid user ID of the key'); 78 | } 79 | } 80 | await this.enforceRateLimit(key); 81 | await this.checkCollision(key); 82 | // check for existing verified key with same ID 83 | const verified = await this.getVerified({keyId: key.keyId}); 84 | if (verified) { 85 | key.userIds = await this._mergeUsers(verified.userIds, key.userIds, key.publicKeyArmored); 86 | // reduce new key to verified user IDs 87 | const filteredPublicKeyArmored = await this._pgp.filterKeyByUserIds(key.userIds.filter(({verified}) => verified), key.publicKeyArmored); 88 | // update verified key with new key 89 | key.publicKeyArmored = await this._pgp.updateKey(verified.publicKeyArmored, filteredPublicKeyArmored); 90 | } else { 91 | key.userIds = key.userIds.filter(userId => userId.status === KEY_STATUS.valid); 92 | if (!key.userIds.length) { 93 | throw Boom.badRequest('Invalid PGP key: no valid user IDs found'); 94 | } 95 | await this._addKeyArmored(key.userIds, key.publicKeyArmored); 96 | // new key, set armored to null 97 | key.publicKeyArmored = null; 98 | this.setVerifyUntil(key); 99 | } 100 | // send mails to verify user IDs 101 | await this._sendVerifyEmail(key, origin, i18n); 102 | // store key in database 103 | await this._persistKey(key); 104 | } 105 | 106 | /** 107 | * Merge existing and new user IDs 108 | * @param {Array} existingUsers source user IDs 109 | * @param {Array} newUsers new user IDs 110 | * @param {String} publicKeyArmored armored key block of new user IDs 111 | * @return {Promise} merged user IDs 112 | */ 113 | async _mergeUsers(existingUsers, newUsers, publicKeyArmored) { 114 | const result = []; 115 | // existing verified valid or revoked users 116 | const verifiedUsers = existingUsers.filter(userId => userId.verified); 117 | // valid new users which are not yet verified 118 | const validUsers = newUsers.filter(userId => userId.status === KEY_STATUS.valid && !this._includeEmail(verifiedUsers, userId)); 119 | // pending users are not verified, not newly submitted 120 | const pendingUsers = existingUsers.filter(userId => !userId.verified && !this._includeEmail(validUsers, userId)); 121 | await this._addKeyArmored(validUsers, publicKeyArmored); 122 | result.push(...validUsers, ...pendingUsers, ...verifiedUsers); 123 | return result; 124 | } 125 | 126 | /** 127 | * Create amored key block which contains the corresponding user ID only and add it to the user ID object 128 | * @param {Array} userIds user IDs to be extended 129 | * @param {String} publicKeyArmored armored key block to be filtered 130 | * @return {Promise} 131 | */ 132 | async _addKeyArmored(userIds, publicKeyArmored) { 133 | for (const userId of userIds) { 134 | userId.publicKeyArmored = await this._pgp.filterKeyByUserIds([userId], publicKeyArmored, config.email.pgp); 135 | userId.notify = true; 136 | } 137 | } 138 | 139 | _includeEmail(users, user) { 140 | return users.find(({email}) => email === user.email); 141 | } 142 | 143 | /** 144 | * Set verifyUntil date to purgeTimeInDays in the future 145 | * @param {Object} key public key parameters 146 | */ 147 | setVerifyUntil(key) { 148 | const verifyUntil = new Date(key.uploaded); 149 | verifyUntil.setDate(key.uploaded.getDate() + config.publicKey.purgeTimeInDays); 150 | key.verifyUntil = verifyUntil; 151 | } 152 | 153 | /** 154 | * Send verification emails to the public keys user IDs for verification. 155 | * If a primary email address is provided only one email will be sent. 156 | * @param {Array} userIds user ID documents containg the verification nonces 157 | * @param {Object} origin the server's origin (required for email links) 158 | * @param {Object} i18n i18n object 159 | * @return {Promise} 175 | */ 176 | async _persistKey(key) { 177 | // delete old/unverified key 178 | await this._mongo.remove({keyId: key.keyId}, DB_TYPE); 179 | // generate nonces for verification 180 | for (const userId of key.userIds) { 181 | // remove status from user 182 | delete userId.status; 183 | // remove notify flag from user 184 | delete userId.notify; 185 | } 186 | // persist new key 187 | const r = await this._mongo.create(key, DB_TYPE); 188 | if (!r.acknowledged) { 189 | throw Boom.badImplementation('Failed to persist key'); 190 | } 191 | } 192 | 193 | /** 194 | * Verify a user ID by proving knowledge of the nonce. 195 | * @param {String} keyId Correspronding public key ID 196 | * @param {String} nonce The verification nonce proving email address ownership 197 | * @return {Promise} The email that has been verified 198 | */ 199 | async verify({keyId, nonce}) { 200 | // look for verification nonce in database 201 | const query = {keyId, 'userIds.nonce': nonce}; 202 | const key = await this._mongo.get(query, DB_TYPE); 203 | if (!key) { 204 | throw Boom.notFound('User ID not found'); 205 | } 206 | await this._removeKeysWithSameEmail(key, nonce); 207 | let {publicKeyArmored, email} = key.userIds.find(userId => userId.nonce === nonce); 208 | // update armored key 209 | if (key.publicKeyArmored) { 210 | publicKeyArmored = await this._pgp.updateKey(key.publicKeyArmored, publicKeyArmored); 211 | } 212 | // flag the user ID as verified 213 | await this._mongo.update(query, { 214 | publicKeyArmored, 215 | 'userIds.$.verified': true, 216 | 'userIds.$.nonce': null, 217 | 'userIds.$.publicKeyArmored': null, 218 | verifyUntil: null 219 | }, DB_TYPE); 220 | return {email}; 221 | } 222 | 223 | /** 224 | * Removes keys with the same email address 225 | * @param {String} options.keyId source key ID 226 | * @param {Array} options.userIds user IDs of source key 227 | * @param {Array} nonce relevant nonce 228 | * @return {Promise} 229 | */ 230 | async _removeKeysWithSameEmail({keyId, userIds}, nonce) { 231 | return this._mongo.remove({ 232 | keyId: {$ne: keyId}, 233 | 'userIds.email': userIds.find(u => u.nonce === nonce).email 234 | }, DB_TYPE); 235 | } 236 | 237 | /** 238 | * Check if a verified key already exists either by fingerprint, 16 char key ID, 239 | * or email address. There can only be one verified user ID for an email address 240 | * at any given time. 241 | * @param {Array} userIds A list of user IDs to check 242 | * @param {String} fingerprint The public key fingerprint 243 | * @param {String} keyId (optional) The public key ID 244 | * @return {Promise} The verified key document 245 | */ 246 | async getVerified({userIds, fingerprint, keyId}) { 247 | let queries = []; 248 | // query by fingerprint 249 | if (fingerprint) { 250 | queries.push({ 251 | fingerprint: fingerprint.toLowerCase(), 252 | 'userIds.verified': true 253 | }); 254 | } 255 | // query by key ID (to prevent key ID collision) 256 | if (keyId) { 257 | queries.push({ 258 | keyId: keyId.toLowerCase(), 259 | 'userIds.verified': true 260 | }); 261 | } 262 | // query by user ID 263 | if (userIds) { 264 | queries = queries.concat(userIds.map(uid => ({ 265 | userIds: { 266 | $elemMatch: { 267 | 'email': util.normalizeEmail(uid.email), 268 | 'verified': true 269 | } 270 | } 271 | }))); 272 | } 273 | return this._mongo.get({$or: queries}, DB_TYPE); 274 | } 275 | 276 | /** 277 | * Fetch a verified public key from the database. Either the key ID or the 278 | * email address muss be provided. 279 | * @param {String} fingerprint (optional) The public key fingerprint 280 | * @param {String} keyId (optional) The public key ID 281 | * @param {String} email (optional) The user's email address 282 | * @param {Object} i18n i18n object 283 | * @return {Promise} The public key document 284 | */ 285 | async get({fingerprint, keyId, email, i18n}) { 286 | // look for verified key 287 | const userIds = email ? [{email}] : undefined; 288 | const key = await this.getVerified({keyId, fingerprint, userIds}); 289 | if (!key) { 290 | throw Boom.notFound(i18n.__('key_not_found')); 291 | } 292 | // clean json return value (_id, nonce) 293 | delete key._id; 294 | key.userIds = key.userIds.map(uid => ({ 295 | name: uid.name, 296 | email: uid.email, 297 | verified: uid.verified 298 | })); 299 | return key; 300 | } 301 | 302 | /** 303 | * Request removal of the public key by flagging all user IDs and sending 304 | * a verification email to the primary email address. Only one email 305 | * needs to sent to a single user ID to authenticate removal of all user IDs 306 | * that belong the a certain key ID. 307 | * @param {String} keyId (optional) The public key ID 308 | * @param {String} email (optional) The user's email address 309 | * @param {Object} origin Required for links to the keyserver e.g. { protocol:'https', host:'openpgpkeys@example.com' } 310 | * @param {Object} i18n i18n object 311 | * @return {Promise} 312 | */ 313 | async requestRemove({keyId, email, origin, i18n}) { 314 | // flag user IDs for removal 315 | const key = await this._flagForRemove(keyId, email); 316 | if (!key) { 317 | throw Boom.notFound('User ID not found'); 318 | } 319 | // send verification mails 320 | keyId = key.keyId; // get keyId in case request was by email 321 | for (const userId of key.userIds) { 322 | await this._email.send({template: tpl.verifyRemove, userId, keyId, origin, i18n}); 323 | } 324 | } 325 | 326 | /** 327 | * Flag all user IDs of a key for removal by generating a new nonce and 328 | * saving it. Either a key ID or email address must be provided 329 | * @param {String} keyId (optional) The public key ID 330 | * @param {String} email (optional) The user's email address 331 | * @return {Promise} A list of user IDs with nonces 332 | */ 333 | async _flagForRemove(keyId, email) { 334 | email = util.normalizeEmail(email); 335 | const query = email ? {'userIds.email': email} : {keyId}; 336 | const key = await this._mongo.get(query, DB_TYPE); 337 | if (!key) { 338 | return; 339 | } 340 | // flag only the provided user ID 341 | if (email) { 342 | const nonce = util.random(); 343 | await this._mongo.update(query, {'userIds.$.nonce': nonce}, DB_TYPE); 344 | const uid = key.userIds.find(u => u.email === email); 345 | uid.nonce = nonce; 346 | return {userIds: [uid], keyId: key.keyId}; 347 | } 348 | // flag all key user IDs 349 | if (keyId) { 350 | for (const uid of key.userIds) { 351 | const nonce = util.random(); 352 | await this._mongo.update({'userIds.email': uid.email}, {'userIds.$.nonce': nonce}, DB_TYPE); 353 | uid.nonce = nonce; 354 | } 355 | return key; 356 | } 357 | } 358 | 359 | /** 360 | * Verify the removal of the user's key ID by proving knowledge of the nonce. 361 | * Also deletes all user ID documents of that key ID. 362 | * @param {String} keyId public key ID 363 | * @param {String} nonce The verification nonce proving email address ownership 364 | * @return {Promise} 365 | */ 366 | async verifyRemove({keyId, nonce}) { 367 | // check if key exists in database 368 | const flagged = await this._mongo.get({keyId, 'userIds.nonce': nonce}, DB_TYPE); 369 | if (!flagged) { 370 | throw Boom.notFound('User ID not found'); 371 | } 372 | if (flagged.userIds.length === 1) { 373 | // delete the key 374 | await this._mongo.remove({keyId}, DB_TYPE); 375 | return flagged.userIds[0]; 376 | } 377 | // update the key 378 | const rmIdx = flagged.userIds.findIndex(userId => userId.nonce === nonce); 379 | const rmUserId = flagged.userIds[rmIdx]; 380 | if (rmUserId.verified) { 381 | if (flagged.userIds.filter(({verified}) => verified).length > 1) { 382 | flagged.publicKeyArmored = await this._pgp.removeUserId(rmUserId.email, flagged.publicKeyArmored); 383 | } else { 384 | flagged.publicKeyArmored = null; 385 | this.setVerifyUntil(flagged); 386 | } 387 | } 388 | flagged.userIds.splice(rmIdx, 1); 389 | await this._mongo.update({keyId}, flagged, DB_TYPE); 390 | return rmUserId; 391 | } 392 | 393 | /** 394 | * Check collision of key ID with existing keys on the server 395 | * @param {Object} key Public key parameters 396 | * @throws {Error} The key failed the collision check 397 | */ 398 | async checkCollision(key) { 399 | const queries = []; 400 | queries.push({keyId: key.keyId, fingerprint: {$ne: key.fingerprint}}); 401 | const newKey = await this._pgp.readKey(key.publicKeyArmored); 402 | for (const subkey of newKey.subkeys) { 403 | queries.push({fingerprint: subkey.getFingerprint()}); 404 | queries.push({keyId: subkey.getKeyID().toHex()}); 405 | } 406 | const found = await this._mongo.count({$or: queries}, DB_TYPE); 407 | if (found) { 408 | log.error('Key ID collision: \n%s\n%s', key.fingerprint, key.publicKeyArmored); 409 | throw Boom.badRequest('Key ID collision error: a key ID of this key already exists on the server.'); 410 | } 411 | } 412 | 413 | /** 414 | * Enforce a rate limit on how many upload operation are allowed per user ID 415 | * @param {Object} key Public key parameters 416 | * @throws {Error} The key exceeds the rate limit 417 | */ 418 | async enforceRateLimit(key) { 419 | const queries = []; 420 | if (!config.publicKey.uploadRateLimit) { 421 | return; 422 | } 423 | for (const userId of key.userIds) { 424 | queries.push({'userIds.email': userId.email}); 425 | } 426 | const found = await this._mongo.count({$or: queries}, DB_TYPE); 427 | if (found > config.publicKey.uploadRateLimit) { 428 | log.error('Too many requests: \n%s\n%s', key.userIds.map(userId => userId.email), key.publicKeyArmored); 429 | throw Boom.tooManyRequests('Too many requests for this email address. Upload temporarily blocked.'); 430 | } 431 | } 432 | } 433 | 434 | module.exports = PublicKey; 435 | -------------------------------------------------------------------------------- /src/lib/closure-library/closure/goog/emailaddress.js: -------------------------------------------------------------------------------- 1 | // Copyright 2010 The Closure Library Authors. All Rights Reserved. 2 | // Copyright (C) 2020 Mailvelope GmbH 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS-IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | /** 17 | * @fileoverview Provides functions to parse and manipulate email addresses. 18 | * Modified from the original source code to include the goog.string dependencies 19 | * 20 | */ 21 | 22 | 'use strict'; 23 | 24 | const goog = {}; 25 | 26 | goog.string = {}; 27 | 28 | /** 29 | * Determines whether a string contains a substring. 30 | * @param {string} str The string to search. 31 | * @param {string} subString The substring to search for. 32 | * @return {boolean} Whether `str` contains `subString`. 33 | */ 34 | goog.string.contains = function(str, subString) { 35 | return str.indexOf(subString) != -1; 36 | }; 37 | 38 | /** 39 | * Converts multiple whitespace chars (spaces, non-breaking-spaces, new lines 40 | * and tabs) to a single space, and strips leading and trailing whitespace. 41 | * @param {string} str Input string. 42 | * @return {string} A copy of `str` with collapsed whitespace. 43 | */ 44 | goog.string.collapseWhitespace = function(str) { 45 | // Since IE doesn't include non-breaking-space (0xa0) in their \s character 46 | // class (as required by section 7.2 of the ECMAScript spec), we explicitly 47 | // include it in the regexp to enforce consistent cross-browser behavior. 48 | return str.replace(/[\s\xa0]+/g, ' ').replace(/^\s+|\s+$/g, ''); 49 | }; 50 | 51 | /** 52 | * Strip quote characters around a string. The second argument is a string of 53 | * characters to treat as quotes. This can be a single character or a string of 54 | * multiple character and in that case each of those are treated as possible 55 | * quote characters. For example: 56 | * 57 | *
 58 |  * goog.string.stripQuotes('"abc"', '"`') --> 'abc'
 59 |  * goog.string.stripQuotes('`abc`', '"`') --> 'abc'
 60 |  * 
61 | * 62 | * @param {string} str The string to strip. 63 | * @param {string} quoteChars The quote characters to strip. 64 | * @return {string} A copy of `str` without the quotes. 65 | */ 66 | goog.string.stripQuotes = function(str, quoteChars) { 67 | const length = quoteChars.length; 68 | for (let i = 0; i < length; i++) { 69 | const quoteChar = length == 1 ? quoteChars : quoteChars.charAt(i); 70 | if (str.charAt(0) == quoteChar && str.charAt(str.length - 1) == quoteChar) { 71 | return str.substring(1, str.length - 1); 72 | } 73 | } 74 | return str; 75 | }; 76 | 77 | /** 78 | * Checks if a string is empty or contains only whitespaces. 79 | * @param {string} str The string to check. 80 | * @return {boolean} Whether `str` is empty or whitespace only. 81 | */ 82 | goog.string.isEmptyOrWhitespace = function(str) { 83 | // testing length == 0 first is actually slower in all browsers (about the 84 | // same in Opera). 85 | // Since IE doesn't include non-breaking-space (0xa0) in their \s character 86 | // class (as required by section 7.2 of the ECMAScript spec), we explicitly 87 | // include it in the regexp to enforce consistent cross-browser behavior. 88 | return /^[\s\xa0]*$/.test(str); 89 | }; 90 | 91 | goog.format = {}; 92 | 93 | /** 94 | * Formats an email address string for display, and allows for extraction of 95 | * the individual components of the address. 96 | * @param {string=} opt_address The email address. 97 | * @param {string=} opt_name The name associated with the email address. 98 | * @constructor 99 | */ 100 | goog.format.EmailAddress = function(opt_address, opt_name) { 101 | /** 102 | * The name or personal string associated with the address. 103 | * @type {string} 104 | * @private 105 | */ 106 | this.name_ = opt_name || ''; 107 | 108 | /** 109 | * The email address. 110 | * @type {string} 111 | * @protected 112 | */ 113 | this.address = opt_address || ''; 114 | }; 115 | 116 | /** 117 | * Match string for opening tokens. 118 | * @type {string} 119 | * @private 120 | */ 121 | goog.format.EmailAddress.OPENERS_ = '"<(['; 122 | 123 | /** 124 | * Match string for closing tokens. 125 | * @type {string} 126 | * @private 127 | */ 128 | goog.format.EmailAddress.CLOSERS_ = '">)]'; 129 | 130 | /** 131 | * Match string for characters that require display names to be quoted and are 132 | * not address separators. 133 | * @type {string} 134 | * @const 135 | * @package 136 | */ 137 | goog.format.EmailAddress.SPECIAL_CHARS = '()<>@:\\\".[]'; 138 | 139 | /** 140 | * Match string for address separators. 141 | * @type {string} 142 | * @const 143 | * @private 144 | */ 145 | goog.format.EmailAddress.ADDRESS_SEPARATORS_ = ',;'; 146 | 147 | /** 148 | * Match string for characters that, when in a display name, require it to be 149 | * quoted. 150 | * @type {string} 151 | * @const 152 | * @private 153 | */ 154 | goog.format.EmailAddress.CHARS_REQUIRE_QUOTES_ = 155 | goog.format.EmailAddress.SPECIAL_CHARS + 156 | goog.format.EmailAddress.ADDRESS_SEPARATORS_; 157 | 158 | /** 159 | * A RegExp to match all double quotes. Used in cleanAddress(). 160 | * @type {RegExp} 161 | * @private 162 | */ 163 | goog.format.EmailAddress.ALL_DOUBLE_QUOTES_ = /\"/g; 164 | 165 | /** 166 | * A RegExp to match escaped double quotes. Used in parse(). 167 | * @type {RegExp} 168 | * @private 169 | */ 170 | goog.format.EmailAddress.ESCAPED_DOUBLE_QUOTES_ = /\\\"/g; 171 | 172 | /** 173 | * A RegExp to match all backslashes. Used in cleanAddress(). 174 | * @type {RegExp} 175 | * @private 176 | */ 177 | goog.format.EmailAddress.ALL_BACKSLASHES_ = /\\/g; 178 | 179 | /** 180 | * A RegExp to match escaped backslashes. Used in parse(). 181 | * @type {RegExp} 182 | * @private 183 | */ 184 | goog.format.EmailAddress.ESCAPED_BACKSLASHES_ = /\\\\/g; 185 | 186 | /** 187 | * A string representing the RegExp for the local part of an email address. 188 | * @private {string} 189 | */ 190 | goog.format.EmailAddress.LOCAL_PART_REGEXP_STR_ = 191 | '[+a-zA-Z0-9_.!#$%&\'*\\/=?^`{|}~-]+'; 192 | 193 | /** 194 | * A string representing the RegExp for the domain part of an email address. 195 | * @private {string} 196 | */ 197 | goog.format.EmailAddress.DOMAIN_PART_REGEXP_STR_ = 198 | '([a-zA-Z0-9-]+\\.)+[a-zA-Z0-9]{2,63}'; 199 | 200 | /** 201 | * A RegExp to match the local part of an email address. 202 | * @private {!RegExp} 203 | */ 204 | goog.format.EmailAddress.LOCAL_PART_ = 205 | new RegExp(`^${goog.format.EmailAddress.LOCAL_PART_REGEXP_STR_}$`); 206 | 207 | /** 208 | * A RegExp to match the domain part of an email address. 209 | * @private {!RegExp} 210 | */ 211 | goog.format.EmailAddress.DOMAIN_PART_ = 212 | new RegExp(`^${goog.format.EmailAddress.DOMAIN_PART_REGEXP_STR_}$`); 213 | 214 | /** 215 | * A RegExp to match an email address. 216 | * @private {!RegExp} 217 | */ 218 | goog.format.EmailAddress.EMAIL_ADDRESS_ = 219 | new RegExp(`^${goog.format.EmailAddress.LOCAL_PART_REGEXP_STR_}@${goog.format.EmailAddress.DOMAIN_PART_REGEXP_STR_}$`); 220 | 221 | /** 222 | * Get the name associated with the email address. 223 | * @return {string} The name or personal portion of the address. 224 | * @final 225 | */ 226 | goog.format.EmailAddress.prototype.getName = function() { 227 | return this.name_; 228 | }; 229 | 230 | /** 231 | * Get the email address. 232 | * @return {string} The email address. 233 | * @final 234 | */ 235 | goog.format.EmailAddress.prototype.getAddress = function() { 236 | return this.address; 237 | }; 238 | 239 | /** 240 | * Set the name associated with the email address. 241 | * @param {string} name The name to associate. 242 | * @final 243 | */ 244 | goog.format.EmailAddress.prototype.setName = function(name) { 245 | this.name_ = name; 246 | }; 247 | 248 | /** 249 | * Set the email address. 250 | * @param {string} address The email address. 251 | * @final 252 | */ 253 | goog.format.EmailAddress.prototype.setAddress = function(address) { 254 | this.address = address; 255 | }; 256 | 257 | /** 258 | * Return the address in a standard format: 259 | * - remove extra spaces. 260 | * - Surround name with quotes if it contains special characters. 261 | * @return {string} The cleaned address. 262 | * @override 263 | */ 264 | goog.format.EmailAddress.prototype.toString = function() { 265 | return this.toStringInternal(goog.format.EmailAddress.CHARS_REQUIRE_QUOTES_); 266 | }; 267 | 268 | /** 269 | * Check if a display name requires quoting. 270 | * @param {string} name The display name 271 | * @param {string} specialChars String that contains the characters that require 272 | * the display name to be quoted. This may change based in whereas we are 273 | * in EAI context or not. 274 | * @return {boolean} 275 | * @private 276 | */ 277 | goog.format.EmailAddress.isQuoteNeeded_ = function(name, specialChars) { 278 | for (let i = 0; i < specialChars.length; i++) { 279 | const specialChar = specialChars[i]; 280 | if (goog.string.contains(name, specialChar)) { 281 | return true; 282 | } 283 | } 284 | return false; 285 | }; 286 | 287 | /** 288 | * Return the address in a standard format: 289 | * - remove extra spaces. 290 | * - Surround name with quotes if it contains special characters. 291 | * @param {string} specialChars String that contains the characters that require 292 | * the display name to be quoted. 293 | * @return {string} The cleaned address. 294 | * @protected 295 | */ 296 | goog.format.EmailAddress.prototype.toStringInternal = function(specialChars) { 297 | let name = this.getName(); 298 | 299 | // We intentionally remove double quotes in the name because escaping 300 | // them to \" looks ugly. 301 | name = name.replace(goog.format.EmailAddress.ALL_DOUBLE_QUOTES_, ''); 302 | 303 | // If the name has special characters, we need to quote it and escape \'s. 304 | if (goog.format.EmailAddress.isQuoteNeeded_(name, specialChars)) { 305 | name = `"${name.replace(goog.format.EmailAddress.ALL_BACKSLASHES_, '\\\\')}"`; 306 | } 307 | 308 | if (name == '') { 309 | return this.address; 310 | } 311 | if (this.address == '') { 312 | return name; 313 | } 314 | return `${name} <${this.address}>`; 315 | }; 316 | 317 | /** 318 | * Determines if the current object is a valid email address. 319 | * @return {boolean} Whether the email address is valid. 320 | */ 321 | goog.format.EmailAddress.prototype.isValid = function() { 322 | return goog.format.EmailAddress.isValidAddrSpec(this.address); 323 | }; 324 | 325 | /** 326 | * Checks if the provided string is a valid email address. Supports both 327 | * simple email addresses (address specs) and addresses that contain display 328 | * names. 329 | * @param {string} str The email address to check. 330 | * @return {boolean} Whether the provided string is a valid address. 331 | */ 332 | goog.format.EmailAddress.isValidAddress = function(str) { 333 | return goog.format.EmailAddress.parse(str).isValid(); 334 | }; 335 | 336 | /** 337 | * Checks if the provided string is a valid address spec (local@domain.com). 338 | * @param {string} str The email address to check. 339 | * @return {boolean} Whether the provided string is a valid address spec. 340 | */ 341 | goog.format.EmailAddress.isValidAddrSpec = function(str) { 342 | // This is a fairly naive implementation, but it covers 99% of use cases. 343 | // For more details, see http://en.wikipedia.org/wiki/Email_address#Syntax 344 | return goog.format.EmailAddress.EMAIL_ADDRESS_.test(str); 345 | }; 346 | 347 | /** 348 | * Checks if the provided string is a valid local part (part before the '@') of 349 | * an email address. 350 | * @param {string} str The local part to check. 351 | * @return {boolean} Whether the provided string is a valid local part. 352 | */ 353 | goog.format.EmailAddress.isValidLocalPartSpec = function(str) { 354 | return goog.format.EmailAddress.LOCAL_PART_.test(str); 355 | }; 356 | 357 | /** 358 | * Checks if the provided string is a valid domain part (part after the '@') of 359 | * an email address. 360 | * @param {string} str The domain part to check. 361 | * @return {boolean} Whether the provided string is a valid domain part. 362 | */ 363 | goog.format.EmailAddress.isValidDomainPartSpec = function(str) { 364 | return goog.format.EmailAddress.DOMAIN_PART_.test(str); 365 | }; 366 | 367 | /** 368 | * Parses an email address of the form "name" <address> ("name" is 369 | * optional) into an email address. 370 | * @param {string} addr The address string. 371 | * @param {function(new: goog.format.EmailAddress, string=,string=)} ctor 372 | * EmailAddress constructor to instantiate the output address. 373 | * @return {!goog.format.EmailAddress} The parsed address. 374 | * @protected 375 | */ 376 | goog.format.EmailAddress.parseInternal = function(addr, ctor) { 377 | // TODO(ecattell): Strip bidi markers. 378 | let name = ''; 379 | let address = ''; 380 | for (let i = 0; i < addr.length;) { 381 | const token = goog.format.EmailAddress.getToken_(addr, i); 382 | if (token.charAt(0) == '<' && token.indexOf('>') != -1) { 383 | const end = token.indexOf('>'); 384 | address = token.substring(1, end); 385 | } else if (address == '') { 386 | name += token; 387 | } 388 | i += token.length; 389 | } 390 | 391 | // Check if it's a simple email address of the form "jlim@google.com". 392 | if (address == '' && name.indexOf('@') != -1) { 393 | address = name; 394 | name = ''; 395 | } 396 | 397 | name = goog.string.collapseWhitespace(name); 398 | name = goog.string.stripQuotes(name, '\''); 399 | name = goog.string.stripQuotes(name, '"'); 400 | // Replace escaped quotes and slashes. 401 | name = name.replace(goog.format.EmailAddress.ESCAPED_DOUBLE_QUOTES_, '"'); 402 | name = name.replace(goog.format.EmailAddress.ESCAPED_BACKSLASHES_, '\\'); 403 | address = goog.string.collapseWhitespace(address); 404 | return new ctor(address, name); 405 | }; 406 | 407 | /** 408 | * Parses an email address of the form "name" <address> into 409 | * an email address. 410 | * @param {string} addr The address string. 411 | * @return {!goog.format.EmailAddress} The parsed address. 412 | */ 413 | goog.format.EmailAddress.parse = function(addr) { 414 | return goog.format.EmailAddress.parseInternal(addr, goog.format.EmailAddress); 415 | }; 416 | 417 | /** 418 | * Parse a string containing email addresses of the form 419 | * "name" <address> into an array of email addresses. 420 | * @param {string} str The address list. 421 | * @param {function(string)} parser The parser to employ. 422 | * @param {function(string):boolean} separatorChecker Accepts a character and 423 | * returns whether it should be considered an address separator. 424 | * @return {!Array} The parsed emails. 425 | * @protected 426 | */ 427 | goog.format.EmailAddress.parseListInternal = function(str, parser, separatorChecker) { 428 | const result = []; 429 | let email = ''; 430 | let token; 431 | 432 | // Remove non-UNIX-style newlines that would otherwise cause getToken_ to 433 | // choke. Remove multiple consecutive whitespace characters for the same 434 | // reason. 435 | str = goog.string.collapseWhitespace(str); 436 | 437 | for (let i = 0; i < str.length;) { 438 | token = goog.format.EmailAddress.getToken_(str, i); 439 | if (separatorChecker(token) || (token == ' ' && parser(email).isValid())) { 440 | if (!goog.string.isEmptyOrWhitespace(email)) { 441 | result.push(parser(email)); 442 | } 443 | email = ''; 444 | i++; 445 | continue; 446 | } 447 | email += token; 448 | i += token.length; 449 | } 450 | 451 | // Add the final token. 452 | if (!goog.string.isEmptyOrWhitespace(email)) { 453 | result.push(parser(email)); 454 | } 455 | return result; 456 | }; 457 | 458 | /** 459 | * Parses a string containing email addresses of the form 460 | * "name" <address> into an array of email addresses. 461 | * @param {string} str The address list. 462 | * @return {!Array} The parsed emails. 463 | */ 464 | goog.format.EmailAddress.parseList = function(str) { 465 | return goog.format.EmailAddress.parseListInternal(str, goog.format.EmailAddress.parse, goog.format.EmailAddress.isAddressSeparator); 466 | }; 467 | 468 | /** 469 | * Get the next token from a position in an address string. 470 | * @param {string} str the string. 471 | * @param {number} pos the position. 472 | * @return {string} the token. 473 | * @private 474 | */ 475 | goog.format.EmailAddress.getToken_ = function(str, pos) { 476 | const ch = str.charAt(pos); 477 | const p = goog.format.EmailAddress.OPENERS_.indexOf(ch); 478 | if (p == -1) { 479 | return ch; 480 | } 481 | if (goog.format.EmailAddress.isEscapedDlQuote_(str, pos)) { 482 | // If an opener is an escaped quote we do not treat it as a real opener 483 | // and keep accumulating the token. 484 | return ch; 485 | } 486 | const closerChar = goog.format.EmailAddress.CLOSERS_.charAt(p); 487 | let endPos = str.indexOf(closerChar, pos + 1); 488 | 489 | // If the closer is a quote we go forward skipping escaped quotes until we 490 | // hit the real closing one. 491 | while (endPos >= 0 && 492 | goog.format.EmailAddress.isEscapedDlQuote_(str, endPos)) { 493 | endPos = str.indexOf(closerChar, endPos + 1); 494 | } 495 | const token = (endPos >= 0) ? str.substring(pos, endPos + 1) : ch; 496 | return token; 497 | }; 498 | 499 | /** 500 | * Checks if the character in the current position is an escaped double quote 501 | * ( \" ). 502 | * @param {string} str the string. 503 | * @param {number} pos the position. 504 | * @return {boolean} true if the char is escaped double quote. 505 | * @private 506 | */ 507 | goog.format.EmailAddress.isEscapedDlQuote_ = function(str, pos) { 508 | if (str.charAt(pos) != '"') { 509 | return false; 510 | } 511 | let slashCount = 0; 512 | for (let idx = pos - 1; idx >= 0 && str.charAt(idx) == '\\'; idx--) { 513 | slashCount++; 514 | } 515 | return ((slashCount % 2) != 0); 516 | }; 517 | 518 | /** 519 | * @param {string} ch The character to test. 520 | * @return {boolean} Whether the provided character is an address separator. 521 | */ 522 | goog.format.EmailAddress.isAddressSeparator = function(ch) { 523 | return goog.string.contains(goog.format.EmailAddress.ADDRESS_SEPARATORS_, ch); 524 | }; 525 | 526 | module.exports = goog; 527 | --------------------------------------------------------------------------------