├── 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 |
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 |
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 |
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 |
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 |
13 |
14 |
15 |
16 |
OpenPGP key upload
17 |
18 | Success!
19 |
20 |
21 | Error!
22 |
23 |
26 |
30 |
31 |
32 |
33 |
46 |
47 |
48 |
OpenPGP key removal
49 |
50 | Success!
51 |
52 |
53 | Error!
54 |
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 | 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
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 | lQcYBFdZY4oBEADHN/tWY4tMdT20T6AzC7VyCNFu5UjSNtw74GHPlyoHuDi4wBLK
107 | J21YfgSEEqv9kvA9BGgT5c68nY2eu6GEE2WQNz90N5xIUTJrhsp2bCcitYgXqvkB
108 | e0U9Ybv3rGcdd/MIdvj2m71N7eHmJy7s1yevhWXpcII7oPTBa5StFr+fs77+LUwL
109 | lOMacwn0KDKFcs7pVI1mJ+0B+2gcE/oXYHtJoCkMnABOO+xG0EtMS1z1amXZJLNB
110 | Wy2WKAv2rosrtHR/Qj/st0fl781WK9E9qVzpttsBuxwOHjJwn/WGRMirj9cl8IuL
111 | us9Iti9e0z1J5d3b3V2APv+U0/WNco2QdstiPiCGBGAVMCrnh7jqfI6WsX2xCRCQ
112 | ObUNVlYKPYrZYhID6diSAXyWdPjewhVS095H3B+8bZk8hnkU72+Z+7lU/UI/Lf8U
113 | OsUaNSaVtktHzvrYErAv+//HlWVCBi6CuWB0SDslZ+V4SS5CzNPpkQ6krvOluJr0
114 | WrmLMwdqwJ7DWxjh+tcuDYot3e7pKsFjDf2lwaUiO9Z00Uf4O8kH9P5ZAzuXNtzh
115 | k/ZESCa4N99R3OuF11127NifMHXeLwtcUblWtW1GUeScvR2OiH7GRlItY9C4RPWY
116 | IqfwDokkcmmv26be5BqI11KiJ4f/dhOthUCsbgeVAdqjtOFSsDHG9JYIewARAQAB
117 | AA/9Etcyh+sGI4b6/PCC4BD9afl3hRteFbNmhKsl1PIg4XYEt0RDAqdT6giQ+MSj
118 | S2n4Gm0uQqN7N89Ws2pfThRfiJIRCDayKwyyzgSDZUu5L8knQ8XBoug7liCGHFhL
119 | sDfF3kkSJpB4CMS0loWiJHf8otbk2nzvdCA2xYwdFXmPSdU//N3f0UCVcczrZhHf
120 | JUvEUcDTVpP0EDnskKs6/bb8MexZtX2TcdKs981/MYn3EqarVyvnYAj1eLv01bGQ
121 | K+P3GIn1bbevrwlMzBd8xG4eAWRvtewyLQuiDZCzMa2TpNYHrOjg6agTLnc8Z6Vm
122 | qHR61O5Mh3JtzW92S5hH1x/FACyIyigLiWIEz/fMEKitkiih1poMkdCAcZPCCkNK
123 | GlSM0eoe5tJE5qR92jxElnH4aH2uDhKKIPiW+ur/0SY2uTYpDBtstojtGBvqB0/D
124 | WRIlEqVydIKF4CfqApa89qCX48SPr4Oddoq4uF0XBrqobEd95PL/GNEw3Iz5ZuiI
125 | VhAdWJC6jX/X2fSdaZcHsM3+Av5tSkPyFlz8/Kv6Pha7GZ2KwD9nTxhvYhcIFbbP
126 | QgBYqXLC7mHSmnRPhicgrmEKERRdXyWwBg0cCDa4nr5fu1o/xBsVDFgMryb8v6Wa
127 | SO09WivRnNrayxFlksBS6gBKWZ2xPDCLv26U0xfYAredMqEIAMi7Gl+envH3jErp
128 | Qz/axY9rVMOVhMI3BeNZ9M4q0a2SReMovwRqiQ1FpuCxV9BjSJ99QotUEJShWPRn
129 | uBC1FSm8vKJf1j74WgGLN6Nt47x5JCCkPrnl5MlRHGcoy2lEO+Jh5ELkpRGwxdsJ
130 | qMmCVbBzmSFGWvwtGUgq1MM70fPltSF3uBqAL8L1vLxnRiWu4cVm9Re0nr4UdM0j
131 | 8UZr+JOUyLp/XVXMpN04B+W3UMhWM6nMr5er6OnLioG+hhJiTLiQ8Z1uw6Q4wH8G
132 | YqQqjoveVLRZi0GU5n+9F2CFScX0HZkx8Qq+UvBj+U09jhUyv5TyhJt5WQPj8pLT
133 | iYToIbkIAP4SSfzpDe2mgvJMSfFa5Zx+8CSjHW5P7lQF1J2z5Gegb4Klir3OB2Zb
134 | n+DHPrqAwq6cNUuWEH1JLKhkpPPcX60ZM2NbwO5ZotWYGFybGvxcqYP33uEFiVeY
135 | dougy9Feif7G/sHViEjJHIy0NFGesPhMJ1Gwy+nBUwdyHCWQmafSvpC3A9ozeEMl
136 | hnRpfBWK8g/kRWBrwcqy6GvMaCzUSHQY5VyUbggzRB6YlMaXp+GBLF4fehBSc71K
137 | UWttfLZw+QkRYooI0TPnJVJgyR3hf4lCLEP8JXNpej9qYg7rz8JWAurDOgVDMTiZ
138 | 5gePO3l1BeBRCrWFOhLeaUGrdGK2pdMIANUK6OxzGz//709jH1UAOgYvD/F9Qz6F
139 | SR2kQ9dH4zm10sbufvjI0I8PLOuEcoFSEbjv6YXnaDBfDzehWkVy1otUuTPbEW3n
140 | 7ootyAnxKqTBMN/XqmqO23OTWZw+4bAaEON6kafYKEkr88AMSuKPFmkzCvAEFqif
141 | wsQa7MybamEnIacCqfJ9BQOC0USZFEYlvxjZLDO6XXwiLtuExlawMBOiPmb+00IJ
142 | waGRraUVbQR5v8zlPXn9LzoXhXL/8OCoyap/mF/ERxkFhjyl96jW6T2e9hgF9aK7
143 | 6Z17LcahNUwsLl0TGus45s/ljpxNHHED2bAiykrlqVUg1XPOJkO8wNCErrQdVGVz
144 | dCBVc2VyIDx0ZXN0MUBleGFtcGxlLmNvbT6JAlMEEwEIAD0CGwMHCwkIBwMCAQYV
145 | CAIJCgsEFgIDAQIeAQIXgAIZARYhBAQGLHC0RuMwFuIZp0ABoSepDejhBQJfo6HB
146 | AAoJEEABoSepDejhC3MP+wbh5uw1yFOcjrS1NyYY3LF/8bi/bV+l6NzAUjDZ3Emd
147 | DPtf6mKqnnjJWCMVZ19sHn31OhoVmS2hHTwIE+Q5Y/j9zeAz2TdN1NlGWCAHIKmh
148 | 8Td/FFiskzvO4SQe9l4sLK9CTt0gUbNdb7i7S62+2umeMC7uw5ZXwviARDI3jCnA
149 | XGV6dMpsQajTUUdIam3P9rDwlnwTHhFun6kJ8Sna25scTT4JuU9sCI40T4G21zCU
150 | mINl1a5CC7wle0dm6evEEC/1IiaTuwmAK1JIc+fOx8Fx5Sp9nBaT9Hqdp8L844Yj
151 | 2L+543AC1wQ4GXBO1+Z96Rw9HkdySaVUbDVyoSAhksmLuEeLjnNeIDebLAwaCA7j
152 | 4lzyf0XxOKRbezq8aMXNiUHw4cFelScRrfJ+gOCaVDtT7w5cpHoMaps2Xmsglc6t
153 | h9/m1ircwPYKJGRwyQBN2X8NwIG8zlgu0Duc8nLBmangwD77jR734HjhxO2J0tye
154 | H8fUh4IwMqV9w7Y+UG0u588VZvHLeNjmXK/4oEPLbcjYRAwMUq6OtWkUThQhKiKV
155 | EjXL09pW5UoweubePneoTH2a9OC8AwKzT7V9ussG8OmblS+tIS5uJe41Dc6DAPfl
156 | bdbougRK/jNsmhgtEaCwB5Sc57x8ZbZf/hWxHPCe0FGc2rcS+1du4FBJt1OhCcmR
157 | iQI/BBMBCAApBQJXWWOKAhsDBQkHhh+ABwsJCAcDAgEGFQgCCQoLBBYCAwECHgEC
158 | F4AACgkQQAGhJ6kN6OGPHg/9ElCnpeTdP0KMJvXlUNw7K3JKqABy+hPolS8zPKr1
159 | JuIXdR7kk69/OiL1+z67pGfTOYrDL8Q9xig/kfd7+AIOQTQ4jnRj1w6mbmO26zUr
160 | Ins9kGFshBC6za5cOAlfpW6DH4tNg0h54S+fHK5FiHdGZ9wwLGdsauM4D3ZzM9K8
161 | hYxgjJkc2zH32GjLk+2N83cH1ytJ5dXVebh+ahMnjQbqG2GrotSmNlgeRTm23x1m
162 | PY9lELtRnHtsTNoZk6MB12WXO4AEZROmk9tl9GAM6a44ROF33rU5B1P5koCXl90W
163 | RG0BWuuj0/aQY+m/gXWbrgiuROSAkpytoL+ocD79s8Wc/xf1MAaF6SG9anrkxMn5
164 | NPHHjBiC4VBr59A5nhpA+ocpbagbDkTQWJ3uAlPkrOaQxf67y/hXRYAZD7w2NWei
165 | bIAhp9X7fB170fAOPKa8PU8v3SbF3gbHjXb0wLgABNgR/aJ4JrKQC0XLvBvwiDEn
166 | VOLKQAGzfbM6t4ElN2FFmr+PH0S2P/fRITyE76hmn2FrAIplsjzBKiPgjy+AtR1E
167 | BDtI6Sc7XzoUuqcR1OgKfTJiAq7lzax/FMiwn5MePiVfHmOJbdyZf++29g+CUFEn
168 | qBXTXuE5+GhUeFD+5fxTmvA2M2dUqdvj7ptYd6YxRk9nddOq4PKAf5vCpY4ZpSvE
169 | mjO0HVRlc3QgVXNlciA8dGVzdDJAZXhhbXBsZS5jb20+iQJQBBMBCAA6AhsDBwsJ
170 | CAcDAgEGFQgCCQoLBBYCAwECHgECF4AWIQQEBixwtEbjMBbiGadAAaEnqQ3o4QUC
171 | X6OhwQAKCRBAAaEnqQ3o4SfED/93pY2daF/5Arg2OcrcVKFF4a5eXxdgPYVbCW6y
172 | nGOEJkg0kzsoy5d2o871I/ngAhONJ0oCpgIVG9KC8UogU3lAbl0NXxMiwkUQ3kcq
173 | Cbu2sudUhP/2DreMhXYZpTGWAwaCdfYKIImHbqryrFonf8T9f6zjr0efpDB3D0Qr
174 | 867P9YmeR/Ap02m1Yu1j5qeATzZOisAVmxCZZsqq1EHJtfrSatjqdjqB2Ey9fOEP
175 | q8MmkHp9LDWZN0BByDSiaCfJsMMLAIFdLXQypeJDJS6uYNKCcaHuRhIYOMPxlcC/
176 | x3cW28QDEudKwB1UvFXIBNSGfxXuVEc31gfHUDINTSi6kS9CaWnibfKDwT7JkP+h
177 | zPfKfDGijUv7BaTOGfZNubt8FkKKZCUj3iNDmw69qehAtHBgpZ0B9xqt96sB+oA9
178 | esF9k0npfh3LWVD8vMyK2Y0qcebiqBr+BFvUdyhhvqX11hSkM4JZGm2H18b4v5KQ
179 | wHGTw2UCBOwhfECes1A6JSjQk24XbwoZN9DG9natqqVuO2dcN5cnJ4zhblmOs35r
180 | dYCGYz9djOaCi6SfJeGnjm/DiLlvkXBEAeYez5jGgKF+37VlGR/DdM27pTxs6yE/
181 | 4QWwMoUySd+Yp7fj6DRALW/H5ZHnWSm6qeesfT9We/gVtDTRi0q7NCylZYdIhRUG
182 | 50ogd7QdVGVzdCBVc2VyIDx0ZXN0M0BleGFtcGxlLmNvbT6JAlAEEwEIADoCGwMH
183 | CwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIXgBYhBAQGLHC0RuMwFuIZp0ABoSepDejh
184 | BQJfo6HBAAoJEEABoSepDejhXrsP/RUplgIQHro7Qhe/yqpSAIn4reTWaCtQDIrj
185 | cHW0+/Gxh4/oHdwjVOY2Q0FeHYo1sXZ1JdgjZLptSQGf42nxHCwg/sj6K1gNtjSx
186 | XYFIvGogQlC9v6tw+x3tUiC3R1sADsayC518ma0hfVOWdwEkB6luZ/SvJdWt6Isn
187 | XHt8SDBjDHuCVEtnZSn6djiNRifUB8cJNVO0f99oZ5eZdLmZPi6qUhGWHRR0+fzt
188 | qLkBYj3rqAb5a/ezUJ8FNGgxO0371pcUX42sF7WtZmaRpKF1cXvXw3srpJXYcu/a
189 | +XkyJgOP1VNgnk2BIYgvEv5LPFSoBiO+i9Sv+DjCz7fxhYVuR07cP8F44q75k3Il
190 | vyu9pC7IUMmIODx4DjllYFFOD25WXAQq8+2JdJ1YemmsTTDH9e1z0gI3qMcAhFop
191 | 7HUmkXrO3T5vx8WhjVanY2IvD7hn7iRYoGE0vqGMJGuBLHMcIqg1qnArZoU5tjc6
192 | aCUhORpWSCp/h7qdgxh8/F4/PkhOg+D5zv0KOSrUhi63vRDWu5ZbFuvcOrfj8RGT
193 | pOcKlthNxNqKo0cCQq9vkdLIT9og76sv0RuSK790MMZLs/2R3XtPG/Rp+1f8LhBY
194 | CMxDtTITCY6lvnk1ZbD9bfv8wdp0I0wxa0Pes8tfXYTuzAnWZlyxht66FUV3gqG0
195 | CJObqXS6tDRUZXN0IFVzZXIgKENvbW1lbnQgYWJvdXQgc3R1ZmYuKSA8dGVzdDRA
196 | ZXhhbXBsZS5jb20+iQJQBBMBCAA6AhsDBwsJCAcDAgEGFQgCCQoLBBYCAwECHgEC
197 | F4AWIQQEBixwtEbjMBbiGadAAaEnqQ3o4QUCX6OhwQAKCRBAAaEnqQ3o4cTlEACa
198 | NLW/lNowfa+bw+acqPHz6EMEcMTYkFZ2e60w9P6+tFqn7IIx+PE5dI70SjwrUGtE
199 | 9jC65X9EloG1gMBEzxNzCmuTEh0m/+A5aNWFEQZgwTu9VKVNO3ZQyhz33tr7xIvZ
200 | cwpp6Tm8Oqc6aW7BEn8gIfxBBHjGGkuQ4VM/W60bGJ45nsh7EkkV6anV33o9sykx
201 | FgRPpNXZSg7P9nLKYTD3QcfMyvizpEM0cMJWFHK8YpBhpSVzTOMrlGfUltrJ22HL
202 | DsnWJtoexWwvo3ItaLDV5iOdD1CccLOfhPPq7NglB8gQwGSzbcw6aBoYwPbl3hGH
203 | yx9F5TIKeWxfUiEmnD1RAz7NgthP6ATglFRL+d1zwcScIegmC2JbP6s9+Y3rtyZo
204 | A6coptmL7tPUZcq+AwmgcTcOA4olMLenmeSHkS815rAYZ1uq/eeDLHE4iB8BJDEf
205 | AkvJutOjqOb0ClMRrAGu8FIBhtGG4WtMlFXw/p1y2X7Gysu4uRkZWbCWshV+lEgt
206 | M54++4KQ6NaVJLJgSKmxh+7B03cO35mHtgag22r4AnQcLHH+4tj8LDleearTnlMC
207 | t86tbt9QgsnXQLHiX5oh0qMdhFVYaX8IfAh2GoVkG46wyuy5x85XNCjLCpmWy+Wc
208 | wKS8gXlg56FZhAqDNvYV2fae3TJ6MEmQ4sFab3D/QZ0HFwRXWWOKARAAzIH3R8va
209 | 5Qm4yg6/QnDtXBCoCefpg/o9vsziIzT0xy+zVT5qqWNp5G7JcKaAkpzBpFRf3CDg
210 | jt47LrPQ/pOdCuAh9de/S4fKjVzEp1pGK5i3JJatrA2v6Fh5f6dMCZ0v0QBD2xRW
211 | oPH+1FcsziIfNAFucoCC6T5YL1khwcs7Jg+1/YyY/opC6ImK4f0LmCjx4iiE+CNH
212 | vo24ZigMaNKexEfhkoySeKlWnd74zDhyDYjCltO5pqMobzmLqie/gGH53YKzSpue
213 | s4exDkbi5IQAkv3SwVsQixz1jAI4U0mobrX6PmV1xx08FrbEDkKgSJnijNCcfM23
214 | TH1MpoV/s8SC+DavWw5HFdBd4fluid4lmJfaYsjVXP8QtpD2DZ/1w1C2XAT72v94
215 | FZG5s3jjwuHAiU4jPNlSUiAMXNCoZ5qa3GDtMeD2l5O4JvKT1lR5yjhdeCnw1rdf
216 | IYui/ryVEzm3C5iHkLepH5g5yLHNPVEWI6v09Vcc2L2zFfv98sZP+BgIX7+NezEN
217 | Ge8ZmCYZsplzJFs8JFeeqdis8GZmr5JigJYCO7bninDKeDjkshMpFTWUIpGI89RC
218 | 25UuLwxmvEF9mjO+Q29ZyVTIS0fF6cUww+8OpqO73SlLmmPL0biOCvNZhko1ufrA
219 | Imq8eRymsY74UQzTmMyY8xA2T0+Mb2Tsgh0AEQEAAQAP9isbpi0tP9tt+3gm7i6b
220 | I2EgeRbwvQ0NYK8fKAlMkLAVnPGJk/GtEiHWzqQZKcj0UwhTfOeNlHkMsfER8ryA
221 | dCkryhmX9IjACtnVg5+Cbl08D5yKSGyLwBNDd1HAIqbwfBQlfX+XNRuWi6xYPaKS
222 | b2KIRS+WNu6WgoyJ+KTfvZ6EKx64+1SmNZjamcQR9GOQ+Q0pEfHdsVzTJ9G2Kro/
223 | BuviM9S9fDosEgZVh3xvPliMEDzkdV3sZqxSRfca7OW9eVu4PVMYO18VYox4jFaZ
224 | XL8yWoFJMRPsZqrUX/04vDSR18b0UZUGi5FAMnzovTRpfXVa8/frwJqHVrIpD6H8
225 | d1GKbmmt4IlMZVg6gOY3ZeesIwkJxAG3HWlL4gdptv7CdlcUaj8OoinTSwCvEnel
226 | b302dLM8OdBqZMw44DovIXx5J1iYIN627o9SKZg8KQbAD7rCwWbWFBRWbBmsABMY
227 | laS2rHmYfnhS1Y7ek9ySFVFTeCVXYzzVOdG96uKJ7ohLUyJ9Rmt7/N1Hj8+duZ1V
228 | lzfpExw4pKP4RWJX3MFkmMEB6KrUXQ1d79c5aP2wx5HUEBeKwuD7EyZrBA/UWVfL
229 | Qant5PUn1aMuzk9eDi1Sftld5WCYHdawgzD/9gg+uq0iMrONLxDo3an9Of8EBgRl
230 | zOjseINc1Bz2tCE+4OQ9yFkIAN6E+IlF1eZUF2FrYh0HksRc3GB12HzSP8Spv5NA
231 | 3Zf/X7Ausy5PDG4QwdPuxqTLKBB8FuJQvxvdqgKwGg+MWoM4pQP5Hqh23CbFN4Ra
232 | z126XTkHhjJ+KZspKEERDXkK7apSIOAiyZ6T+/32muh1GyCWggjhAUPZB2ZwSJKx
233 | RTH78BUl9nGhYn1oGPyg5aFeeqnreOfxRIL+1xwmDz/66dFik1lERb7lAtl7k9zJ
234 | tfJm1JtJaXdKQIRE5ASfVP2qbLwUspHawchodkewgWR/Tl0dOJ7Op7tSwRKYkF5Z
235 | Bhd2XVEYldz6G74U2h7adA/gkLY+3h6JrzLpIuwdxQ8HVpUIAOtHN4N7Xwqq7sno
236 | DoxRaqxkW4sjzMwuMWbkbVIRMskC2sfxWKzP7hhCZ/pdnMFpwazpiOkyXvTtGqRO
237 | ysi/2hMj6j95HpggijK0ww9zeGAktqVRC5TnygHrbnB70XRdo3LpsOJ1yXQ16PjU
238 | qDseGRtnjDbdSX2lGmlcwahIw6F56LvXVVMzDv4omdMmw0j2+OMmzK3M2XrBl2Sa
239 | pZQ9rC8cU1y0r4wCpH3EL5T+QyEfCnUlg6be6/7suQYWTzRx3Svy/wWCvFyz+NDL
240 | 108JiNtCoTdVoPJkKdq9KRBx1diQO3clVBDCia3phVm3n0BLh3YcqpcUv4NUZeoY
241 | wEb9Q2kIAJkNpTnPsDFeVvxu+tDxiYN+aqgDSHNne7sdIk/niw7q2ShklnbHIBz2
242 | l9wIV8xU1VEZSBrWvWC51q9xhYSBH0TsZ7Lz5uuFTqtTYgu/29EYJ32xxr7Ao42q
243 | B3DFdJYiQ4ZnneDIpw7GPLwhkZWylujJqyrHhbyiTphDhhlF02OwRFJiZCKNAaJZ
244 | 1qx7+KMcANoaaecyXmYfDOvWcBgETAIcFMOO0t1jlkY9ZH8Kz9hVpnLt8BSoPs2i
245 | K9+tYaxZZoAlz7X7cnouXKKoy3S7wLogJDyJwaQaudVjCsDfCLsE0Z5l82LiaPl6
246 | 0CRazXmlBiE2yJpJEvvtT+/BPnFgNxx7T4kCNgQYAQgAIAIbDBYhBAQGLHC0RuMw
247 | FuIZp0ABoSepDejhBQJfo6GLAAoJEEABoSepDejhzmsP/2T8I44qgefeTShJc7mY
248 | nUX1kb8NGejaYUw+CuXzzx8ESi/xdgEQEN1o+ZqLe2e+ULLLyLCHGfkurMVePsdl
249 | IwaygHpBoHRcF5Hkt7z69rc8Vo1VyxKdlIi57bfbS80vMSwMw4Kb+pk7HpxGnSix
250 | 8uMJRsc/fctffQ4lR+DFpxQXlUg/4DfbZ4kn1UCKnha3T0iydQAOWtSYUYDqvIKI
251 | gWt4RAgZe8ykC16ryEeIW0s6XIfhdBjKOnh0Pql8zQRt+XFDVVH9Jpdwpk/hSlbA
252 | 2tj0xb8wdplkdJ1sIYO0Jc125b0WZMOi2d5fb7Txnk7Nq+hgBuM4conS8vlw7mDn
253 | VhyHgDa21wYq9nXZ8/h4FLzW3vPc4pl7jxSo3MGB0wh90MPRNOX8dI/MX3RFS/+w
254 | BSrhxbU0F1WNEs/JOsi6/zibCNw+E53so0TydADRbumcQ9FWsmMic+dJFk6/ph7v
255 | fNgK18VG+YzXAnVa71OEQxemtXSyl90P7e48UdlRf0sPy7AZ+I8XT4JbFrLD+e/m
256 | potcd8Q+YV2d7zixfpEMdvSbGPQZawNU4NUZg/S+5TPpATj3UucNxhQfo/WK8tZP
257 | wNeKfbuIiUVwq0xaNvwPQq58MY6kar3yRidFrdXVK9b05iVQHZsffMlnvLJ1vfxe
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 |
--------------------------------------------------------------------------------