diff --git a/README.md b/README.md index 3e6e4d8..51a8cef 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,12 @@ The idea is that an identity provider such as an email provider can host their o +# Demo + +Try out the server here: [https://keys.mailvelope.com](https://keys.mailvelope.com) + + + # Api The key server provides a modern RESTful api, but is also backwards compatible to the OpenPGP HTTP Keyserver Protocol (HKP). @@ -83,7 +89,8 @@ GET /user/user@example.com "userIds": [ { "name": "Jon Smith", - "email": "jon@smith.com" + "email": "jon@smith.com", + "verified": "true" } ], "created": "Sat Oct 17 2015 12:17:03 GMT+0200 (CEST)", @@ -95,7 +102,9 @@ GET /user/user@example.com * **keyId**: The 16 char key id in hex * **fingerprint**: The 40 char key fingerprint in hex -* **userIds**: An array of the public key's user IDs +* **userIds.name**: The user ID's name +* **userIds.email**: The user ID's email address +* **userIds.verified**: If the user ID's email address has been verified * **created**: The key creation time as a JavaScript Date * **algorithm**: The primary key alogrithm * **keySize**: The key length in bits @@ -128,16 +137,17 @@ GET /api/v1/verify?keyId=b8e4105cc9dedc77&nonce=123e4567-e89b-12d3-a456-42665544 ### Request key removal -#### By key id +#### Via delete request ``` -DELETE /api/v1/key?keyId=b8e4105cc9dedc77 +DELETE /api/v1/key?keyId=b8e4105cc9dedc77 OR ?email=user@example.com ``` -#### By email address +#### Via link ``` -DELETE /api/v1/key?email=user@example.com +GET /api/v1/removeKey?keyId=b8e4105cc9dedc77 OR ?email=user@example.com +``` ``` ### Verify key removal diff --git a/src/app.js b/src/app.js index d4a4862..d8f289d 100644 --- a/src/app.js +++ b/src/app.js @@ -22,52 +22,56 @@ const app = require('koa')(); const log = require('npmlog'); const config = require('config'); const router = require('koa-router')(); -const openpgp = require('openpgp'); -const nodemailer = require('nodemailer'); -const openpgpEncrypt = require('nodemailer-openpgp').openpgpEncrypt; const Mongo = require('./dao/mongo'); const Email = require('./email/email'); -const UserId = require('./service/user-id'); +const PGP = require('./service/pgp'); const PublicKey = require('./service/public-key'); const HKP = require('./route/hkp'); const REST = require('./route/rest'); +const home = require('./route/home'); -let mongo, email, userId, publicKey, hkp, rest; +let mongo, email, pgp, publicKey, hkp, rest; // // Configure koa HTTP server // // HKP routes -router.post('/pks/add', function *() { // no query params +router.post('/pks/add', function *() { yield hkp.add(this); }); -router.get('/pks/lookup', function *() { // ?op=get&search=0x1234567890123456 +router.get('/pks/lookup', function *() { yield hkp.lookup(this); }); // REST api routes -router.post('/api/v1/key', function *() { // { publicKeyArmored, primaryEmail } hint the primary email address +router.post('/api/v1/key', function *() { yield rest.create(this); }); -router.get('/api/v1/key', function *() { // ?keyid=keyid OR ?email=email +router.get('/api/v1/key', function *() { yield rest.read(this); }); -router.del('/api/v1/key', function *() { // ?keyid=keyid OR ?email=email +router.del('/api/v1/key', function *() { yield rest.remove(this); }); -// links for verification and sharing -router.get('/api/v1/verify', function *() { // ?keyid=keyid&nonce=nonce +// links for verification, removal and sharing +router.get('/api/v1/verify', function *() { yield rest.verify(this); }); -router.get('/api/v1/verifyRemove', function *() { // ?keyid=keyid&nonce=nonce +router.get('/api/v1/removeKey', function *() { + yield rest.remove(this); +}); +router.get('/api/v1/verifyRemove', function *() { yield rest.verifyRemove(this); }); -router.get('/user/:email', function *() { // shorthand link for sharing +router.get('/user/:email', function *() { yield rest.share(this); }); +// display homepage +router.get('/', home); + // Set HTTP response headers app.use(function *(next) { this.set('Strict-Transport-Security', 'max-age=16070400'); @@ -75,7 +79,6 @@ app.use(function *(next) { this.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); this.set('Access-Control-Allow-Headers', 'Content-Type'); this.set('Cache-Control', 'no-cache'); - this.set('Pragma', 'no-cache'); this.set('Connection', 'keep-alive'); yield next; }); @@ -105,13 +108,12 @@ app.on('error', (error, ctx) => { // function injectDependencies() { - mongo = new Mongo(config.mongo); - email = new Email(nodemailer, openpgpEncrypt); - email.init(config.email); - userId = new UserId(mongo); - publicKey = new PublicKey(openpgp, mongo, email, userId); + mongo = new Mongo(); + email = new Email(); + pgp = new PGP(); + publicKey = new PublicKey(pgp, mongo, email); hkp = new HKP(publicKey); - rest = new REST(publicKey, userId); + rest = new REST(publicKey); } // @@ -129,8 +131,9 @@ if (!global.testing) { // don't automatically start server in tests function *init() { log.level = config.log.level; // set log level depending on process.env.NODE_ENV injectDependencies(); + email.init(config.email); log.info('app', 'Connecting to MongoDB ...'); - yield mongo.connect(); + yield mongo.init(config.mongo); return app; } diff --git a/src/route/hkp.js b/src/route/hkp.js index afb9350..78c6364 100644 --- a/src/route/hkp.js +++ b/src/route/hkp.js @@ -41,7 +41,7 @@ class HKP { *add(ctx) { let body = yield parse.form(ctx, { limit: '1mb' }); let publicKeyArmored = body.keytext; - if (!util.validatePublicKey(publicKeyArmored)) { + if (!publicKeyArmored) { ctx.throw(400, 'Invalid request!'); } let origin = util.getOrigin(ctx); @@ -72,14 +72,16 @@ class HKP { mr: ctx.query.options === 'mr' // machine readable }; if (this.checkId(ctx.query.search)) { - params.keyid = ctx.query.search.replace(/^0x/, ''); - } else if(util.validateAddress(ctx.query.search)) { + let id = ctx.query.search.replace(/^0x/, ''); + params.keyId = util.isKeyId(id) ? id : undefined; + params.fingerprint = util.isFingerPrint(id) ? id : undefined; + } else if (util.isEmail(ctx.query.search)) { params.email = ctx.query.search; } if (['get','index','vindex'].indexOf(params.op) === -1) { ctx.throw(501, 'Not implemented!'); - } else if (!params.keyid && !params.email) { + } else if (!params.keyId && !params.fingerprint && !params.email) { ctx.throw(501, 'Not implemented!'); } @@ -89,14 +91,14 @@ class HKP { /** * Checks for a valid key id in the query string. A key must be prepended * with '0x' and can be between 16 and 40 hex characters long. - * @param {String} keyid The key id - * @return {Boolean} If the key id is valid + * @param {String} id The key id + * @return {Boolean} If the key id is valid */ - checkId(keyid) { - if (!util.isString(keyid)) { + checkId(id) { + if (!util.isString(id)) { return false; } - return /^0x[a-fA-F0-9]{16,40}$/.test(keyid); + return /^0x[a-fA-F0-9]{16,40}$/.test(id); } /** @@ -123,11 +125,12 @@ class HKP { ctx.body = key.publicKeyArmored; } else if (['index','vindex'].indexOf(params.op) !== -1) { const VERSION = 1, COUNT = 1; // number of keys + let fp = key.fingerprint.toUpperCase(); let algo = (key.algorithm.indexOf('rsa') !== -1) ? 1 : ''; let created = key.created ? (key.created.getTime() / 1000) : ''; ctx.body = 'info:' + VERSION + ':' + COUNT + '\n' + - 'pub:' + key.fingerprint + ':' + algo + ':' + key.keylen + ':' + created + '::\n'; + 'pub:' + fp + ':' + algo + ':' + key.keySize + ':' + created + '::\n'; for (let uid of key.userIds) { ctx.body += 'uid:' + encodeURIComponent(uid.name + ' <' + uid.email + '>') + ':::\n'; diff --git a/src/route/home.js b/src/route/home.js new file mode 100644 index 0000000..d5415ae --- /dev/null +++ b/src/route/home.js @@ -0,0 +1,23 @@ +'use strict'; + +module.exports = function () { + let hkp = (this.secure ? 'hkps://' : 'hkp://') + this.host; + let del = this.origin + '/api/v1/removeKey?email=user@example.com'; + this.body = + ` +

Welcome to the OpenPGP key server

+

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

+

Try it out

+
    +
  1. Configure this key server in your HKP compatible OpenPGP client using this url: ${hkp}
  2. +
  3. Now just upload a public key like you always do.
  4. +
  5. Check your inbox and click on the verification link inside the encrypted message.
  6. +
  7. You can delete all your data from the server at any time using this link: ${del}
  8. +
+

Documentation and code

+

Please refer to the documentation to learn more about the api.

+

License AGPL v3.0

+ `; + + this.set('Content-Type', 'text/html; charset=utf-8'); +}; \ No newline at end of file diff --git a/src/route/rest.js b/src/route/rest.js index 260e403..35b8c98 100644 --- a/src/route/rest.js +++ b/src/route/rest.js @@ -30,9 +30,8 @@ class REST { * @param {Object} publicKey An instance of the public key service * @param {Object} userId An instance of the user id service */ - constructor(publicKey, userId) { + constructor(publicKey) { this._publicKey = publicKey; - this._userId = userId; } /** @@ -42,8 +41,7 @@ class REST { *create(ctx) { let q = yield parse.json(ctx, { limit: '1mb' }); let publicKeyArmored = q.publicKeyArmored, primaryEmail = q.primaryEmail; - if (!util.validatePublicKey(publicKeyArmored) || - (primaryEmail && !util.validateAddress(primaryEmail))) { + if (!publicKeyArmored || (primaryEmail && !util.isEmail(primaryEmail))) { ctx.throw(400, 'Invalid request!'); } let origin = util.getOrigin(ctx); @@ -56,11 +54,11 @@ class REST { * @param {Object} ctx The koa request/response context */ *verify(ctx) { - let q = { keyid:ctx.query.keyid, nonce:ctx.query.nonce }; - if (!util.validateKeyId(q.keyid) || !util.isString(q.nonce)) { + let q = { keyId:ctx.query.keyId, nonce:ctx.query.nonce }; + if (!util.isKeyId(q.keyId) || !util.isString(q.nonce)) { ctx.throw(400, 'Invalid request!'); } - yield this._userId.verify(q); + yield this._publicKey.verify(q); ctx.body = 'Key successfully verified!'; } @@ -69,8 +67,8 @@ class REST { * @param {Object} ctx The koa request/response context */ *read(ctx) { - let q = { keyid:ctx.query.keyid, email:ctx.query.email }; - if (!util.validateKeyId(q.keyid) && !util.validateAddress(q.email)) { + let q = { keyId:ctx.query.keyId, fingerprint:ctx.query.fingerprint, email:ctx.query.email }; + if (!util.isKeyId(q.keyId) && !util.isFingerPrint(q.fingerprint) && !util.isEmail(q.email)) { ctx.throw(400, 'Invalid request!'); } ctx.body = yield this._publicKey.get(q); @@ -82,7 +80,7 @@ class REST { */ *share(ctx) { let q = { email:ctx.params.email }; - if (!util.validateAddress(q.email)) { + if (!util.isEmail(q.email)) { ctx.throw(400, 'Invalid request!'); } ctx.body = (yield this._publicKey.get(q)).publicKeyArmored; @@ -93,8 +91,8 @@ class REST { * @param {Object} ctx The koa request/response context */ *remove(ctx) { - let q = { keyid:ctx.query.keyid, email:ctx.query.email, origin:util.getOrigin(ctx) }; - if (!util.validateKeyId(q.keyid) && !util.validateAddress(q.email)) { + let q = { keyId:ctx.query.keyId, email:ctx.query.email, origin:util.getOrigin(ctx) }; + if (!util.isKeyId(q.keyId) && !util.isEmail(q.email)) { ctx.throw(400, 'Invalid request!'); } yield this._publicKey.requestRemove(q); @@ -106,8 +104,8 @@ class REST { * @param {Object} ctx The koa request/response context */ *verifyRemove(ctx) { - let q = { keyid:ctx.query.keyid, nonce:ctx.query.nonce }; - if (!util.validateKeyId(q.keyid) || !util.isString(q.nonce)) { + let q = { keyId:ctx.query.keyId, nonce:ctx.query.nonce }; + if (!util.isKeyId(q.keyId) || !util.isString(q.nonce)) { ctx.throw(400, 'Invalid request!'); } yield this._publicKey.verifyRemove(q); diff --git a/test/integration/app-test.js b/test/integration/app-test.js index b826572..6cf6a7b 100644 --- a/test/integration/app-test.js +++ b/test/integration/app-test.js @@ -108,7 +108,7 @@ describe('Koa App (HTTP Server) Integration Tests', function() { it('should return 200 for valid params', done => { request(app.listen()) - .get('/api/v1/verify?keyid=' + emailParams.keyid + '&nonce=' + emailParams.nonce) + .get('/api/v1/verify?keyId=' + emailParams.keyId + '&nonce=' + emailParams.nonce) .expect(200) .end(done); }); @@ -122,7 +122,7 @@ describe('Koa App (HTTP Server) Integration Tests', function() { it('should return 400 for missing nonce', done => { request(app.listen()) - .get('/api/v1/verify?keyid=' + emailParams.keyid) + .get('/api/v1/verify?keyId=' + emailParams.keyId) .expect(400) .end(done); }); @@ -140,7 +140,7 @@ describe('Koa App (HTTP Server) Integration Tests', function() { describe('Not yet verified', () => { it('should return 404', done => { request(app.listen()) - .get('/api/v1/key?keyid=' + emailParams.keyid) + .get('/api/v1/key?keyId=' + emailParams.keyId) .expect(404).end(done); }); }); @@ -148,14 +148,14 @@ describe('Koa App (HTTP Server) Integration Tests', function() { describe('Verified', () => { beforeEach(done => { request(app.listen()) - .get('/api/v1/verify?keyid=' + emailParams.keyid + '&nonce=' + emailParams.nonce) + .get('/api/v1/verify?keyId=' + emailParams.keyId + '&nonce=' + emailParams.nonce) .expect(200) .end(done); }); it('should return 200 and get key by id', done => { request(app.listen()) - .get('/api/v1/key?keyid=' + emailParams.keyid) + .get('/api/v1/key?keyId=' + emailParams.keyId) .expect(200) .end(done); }); @@ -176,14 +176,14 @@ describe('Koa App (HTTP Server) Integration Tests', function() { it('should return 400 for short key id', done => { request(app.listen()) - .get('/api/v1/key?keyid=0123456789ABCDE') + .get('/api/v1/key?keyId=0123456789ABCDE') .expect(400) .end(done); }); it('should return 404 for wrong key id', done => { request(app.listen()) - .get('/api/v1/key?keyid=0123456789ABCDEF') + .get('/api/v1/key?keyId=0123456789ABCDEF') .expect(404) .end(done); }); @@ -211,7 +211,7 @@ describe('Koa App (HTTP Server) Integration Tests', function() { describe('Verified', () => { beforeEach(done => { request(app.listen()) - .get('/api/v1/verify?keyid=' + emailParams.keyid + '&nonce=' + emailParams.nonce) + .get('/api/v1/verify?keyId=' + emailParams.keyId + '&nonce=' + emailParams.nonce) .expect(200) .end(done); }); @@ -257,7 +257,7 @@ describe('Koa App (HTTP Server) Integration Tests', function() { it('should return 202 for key id', done => { request(app.listen()) - .del('/api/v1/key?keyid=' + emailParams.keyid) + .del('/api/v1/key?keyId=' + emailParams.keyId) .expect(202) .end(done); }); @@ -284,6 +284,23 @@ describe('Koa App (HTTP Server) Integration Tests', function() { }); }); + describe('GET /api/v1/removeKey', () => { + beforeEach(done => { + request(app.listen()) + .post('/api/v1/key') + .send({ publicKeyArmored, primaryEmail }) + .expect(201) + .end(done); + }); + + it('should return 202 for key id', done => { + request(app.listen()) + .get('/api/v1/removeKey?keyId=' + emailParams.keyId) + .expect(202) + .end(done); + }); + }); + describe('GET /api/v1/verifyRemove', () => { beforeEach(done => { request(app.listen()) @@ -292,7 +309,7 @@ describe('Koa App (HTTP Server) Integration Tests', function() { .expect(201) .end(function() { request(app.listen()) - .del('/api/v1/key?keyid=' + emailParams.keyid) + .del('/api/v1/key?keyId=' + emailParams.keyId) .expect(202) .end(done); }); @@ -300,7 +317,7 @@ describe('Koa App (HTTP Server) Integration Tests', function() { it('should return 200 for key id', done => { request(app.listen()) - .get('/api/v1/verifyRemove?keyid=' + emailParams.keyid + '&nonce=' + emailParams.nonce) + .get('/api/v1/verifyRemove?keyId=' + emailParams.keyId + '&nonce=' + emailParams.nonce) .expect(200) .end(done); }); @@ -314,7 +331,7 @@ describe('Koa App (HTTP Server) Integration Tests', function() { it('should return 404 for unknown key id', done => { request(app.listen()) - .get('/api/v1/verifyRemove?keyid=0123456789ABCDEF&nonce=' + emailParams.nonce) + .get('/api/v1/verifyRemove?keyId=0123456789ABCDEF&nonce=' + emailParams.nonce) .expect(404) .end(done); }); @@ -355,7 +372,7 @@ describe('Koa App (HTTP Server) Integration Tests', function() { describe('Not yet verified', () => { it('should return 404', done => { request(app.listen()) - .get('/pks/lookup?op=get&search=0x' + emailParams.keyid) + .get('/pks/lookup?op=get&search=0x' + emailParams.keyId) .expect(404) .end(done); }); @@ -364,14 +381,14 @@ describe('Koa App (HTTP Server) Integration Tests', function() { describe('Verified', () => { beforeEach(done => { request(app.listen()) - .get('/api/v1/verify?keyid=' + emailParams.keyid + '&nonce=' + emailParams.nonce) + .get('/api/v1/verify?keyId=' + emailParams.keyId + '&nonce=' + emailParams.nonce) .expect(200) .end(done); }); it('should return 200 for key id', done => { request(app.listen()) - .get('/pks/lookup?op=get&search=0x' + emailParams.keyid) + .get('/pks/lookup?op=get&search=0x' + emailParams.keyId) .expect(200, publicKeyArmored) .end(done); }); @@ -401,14 +418,14 @@ describe('Koa App (HTTP Server) Integration Tests', function() { it('should return 200 for "vindex" op', done => { request(app.listen()) - .get('/pks/lookup?op=vindex&search=0x' + emailParams.keyid) + .get('/pks/lookup?op=vindex&search=0x' + emailParams.keyId) .expect(200) .end(done); }); it('should return 200 for "index" with "mr" option', done => { request(app.listen()) - .get('/pks/lookup?op=index&options=mr&search=0x' + emailParams.keyid) + .get('/pks/lookup?op=index&options=mr&search=0x' + emailParams.keyId) .expect('Content-Type', 'text/plain; charset=utf-8') .expect(200) .end(done); @@ -437,7 +454,7 @@ describe('Koa App (HTTP Server) Integration Tests', function() { it('should return 501 for a invalid key id format', done => { request(app.listen()) - .get('/pks/lookup?op=get&search=' + emailParams.keyid) + .get('/pks/lookup?op=get&search=' + emailParams.keyId) .expect(501) .end(done); }); @@ -458,7 +475,7 @@ describe('Koa App (HTTP Server) Integration Tests', function() { it('should return 501 (Not implemented) for "x-email" op', done => { request(app.listen()) - .get('/pks/lookup?op=x-email&search=0x' + emailParams.keyid) + .get('/pks/lookup?op=x-email&search=0x' + emailParams.keyId) .expect(501) .end(done); });