From 2d07c3406032107ddce4f03b2f58f9381c511703 Mon Sep 17 00:00:00 2001 From: Tankred Hase Date: Fri, 27 May 2016 19:57:48 +0200 Subject: [PATCH] First working prototype of the keyserver --- config/default.js | 10 +++ config/production.js | 7 ++ package.json | 9 ++- server.js | 3 + src/ctrl/public-key.js | 104 ++++++++++++++++++++++++++--- src/ctrl/user-id.js | 114 ++++++++++++++++++++++++++++++++ src/ctrl/util.js | 109 ++++++++++++++++++++++++++++++ src/dao/email.js | 27 +++++++- src/dao/mongo.js | 76 +++++++++++---------- src/routes/hkp.js | 62 +++++++---------- src/routes/rest.js | 54 +++++++++++++-- src/worker.js | 50 ++++++++++---- test/integration/mongo-test.js | 105 ++++++++++++----------------- test/integration/worker-test.js | 69 +++++++++++++++++++ test/key1.asc | 32 +++++++++ 15 files changed, 664 insertions(+), 167 deletions(-) create mode 100644 config/default.js create mode 100644 config/production.js create mode 100644 src/ctrl/user-id.js create mode 100644 src/ctrl/util.js create mode 100644 test/integration/worker-test.js create mode 100644 test/key1.asc diff --git a/config/default.js b/config/default.js new file mode 100644 index 0000000..db0b55f --- /dev/null +++ b/config/default.js @@ -0,0 +1,10 @@ +'use strict'; + +module.exports = { + server: { + port: process.env.PORT || 8888, + }, + log: { + level: "silly" + } +}; \ No newline at end of file diff --git a/config/production.js b/config/production.js new file mode 100644 index 0000000..c3b288d --- /dev/null +++ b/config/production.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = { + log: { + level: "error" + } +}; \ No newline at end of file diff --git a/package.json b/package.json index 88648de..836d857 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,15 @@ "test": "grunt test" }, "dependencies": { + "addressparser": "^1.0.1", "co": "^4.6.0", + "co-body": "^4.2.0", "koa": "^1.2.0", "koa-router": "^5.4.0", "mongodb": "^2.1.20", - "npmlog": "^2.0.4" + "nodemailer": "^2.4.2", + "npmlog": "^2.0.4", + "openpgp": "^2.3.0" }, "devDependencies": { "chai": "^3.5.0", @@ -27,6 +31,7 @@ "grunt-jscs": "^2.8.0", "grunt-mocha-test": "^0.12.7", "mocha": "^2.5.3", - "sinon": "^1.17.4" + "sinon": "^1.17.4", + "supertest": "^1.2.0" } } diff --git a/server.js b/server.js index 9143602..8fb0edb 100644 --- a/server.js +++ b/server.js @@ -19,8 +19,11 @@ const cluster = require('cluster'); const numCPUs = require('os').cpus().length; +const config = require('config'); const log = require('npmlog'); +log.level = config.log.level; // set log level depending on process.env.NODE_ENV + // // Start worker cluster depending on number of CPUs // diff --git a/src/ctrl/public-key.js b/src/ctrl/public-key.js index 2c2b6d4..605e3d1 100644 --- a/src/ctrl/public-key.js +++ b/src/ctrl/public-key.js @@ -17,11 +17,13 @@ 'use strict'; +const log = require('npmlog'); +const util = require('./util'); + /** * Database documents have the format: * { - * _id: "02C134D079701934", // the 16 byte key id - * email: "jon@example.com", // the primary and verified email address + * _id: "02C134D079701934", // the 16 byte key id in uppercase hex * publicKeyArmored: "-----BEGIN PGP PUBLIC KEY BLOCK----- ... -----END PGP PUBLIC KEY BLOCK-----" * } */ @@ -34,21 +36,73 @@ class PublicKey { /** * Create an instance of the controller - * @param {Object} mongo An instance of the MongoDB client + * @param {Object} openpgp An instance of OpenPGP.js + * @param {Object} mongo An instance of the MongoDB client + * @param {Object} email An instance of the Email Sender + * @param {Object} userid An instance of the UserId controller */ - constructor(mongo) { + constructor(openpgp, mongo, email, userid) { + this._openpgp = openpgp; this._mongo = mongo; + this._email = email; + this._userid = userid; } // // Create/Update // - put(options) { - + /** + * Persist a new public key + * @param {String} options.publicKeyArmored The ascii armored pgp key block + * @param {String} options.primaryEmail (optional) The key's primary email address + * @yield {undefined} + */ + *put(options) { + // parse key block + let publicKeyArmored = options.publicKeyArmored; + let params = this.parseKey(publicKeyArmored); + // check for existing verfied key by id or email addresses + let verified = yield this._userid.getVerfied(params); + if (verified) { + throw util.error(304, 'Key for this user already exists: ' + verified.stringify()); + } + // delete old/unverified key and user ids with the same key id + yield this.remove({ keyid:params.keyid }); + // persist new key + let r = yield this._mongo.create({ _id:params.keyid, publicKeyArmored }, DB_TYPE); + if (r.insertedCount !== 1) { + throw util.error(500, 'Failed to persist key'); + } + // persist new user ids + let userIds = yield this._userid.batch(params); + yield this._email.sendVerification({ userIds, primaryEmail:options.primaryEmail }); } - verify(options) { + /** + * Parse an ascii armored pgp key block and get its parameters. + * @param {String} publicKeyArmored The ascii armored pgp key block + * @return {Object} The key's id and user ids + */ + parseKey(publicKeyArmored) { + let keys, userIds = []; + try { + keys = this._openpgp.key.readArmored(publicKeyArmored).keys; + } catch(e) { + log.error('public-key', 'Failed to parse PGP key:\n%s', publicKeyArmored, e); + throw util.error(500, 'Failed to parse PGP key'); + } + // get key user ids + keys.forEach(key => userIds = userIds.concat(key.getUserIds())); + userIds = util.deDup(userIds); + // get key id + return { + keyid: keys[0].primaryKey.getKeyId().toHex().toUpperCase(), + userIds: util.parseUserIds(userIds) + }; + } + + verify() { } @@ -56,22 +110,50 @@ class PublicKey { // Read // - get(options) { - + /** + * Fetch a verified public key from the database. Either the key id or the + * email address muss be provided. + * @param {String} options.keyid (optional) The public key id + * @param {String} options.email (optional) The user's email address + * @yield {Object} The public key document + */ + *get(options) { + let keyid = options.keyid, email = options.email; + let verified = yield this._userid.getVerfied({ + keyid: keyid ? keyid.toUpperCase() : undefined, + userIds: email ? [{ email:email.toLowerCase() }] : undefined + }); + if (verified) { + return yield this._mongo.get({ _id:verified.keyid }, DB_TYPE); + } else { + throw util.error(404, 'Key not found'); + } } // // Delete // - remove(options) { + flagForRemove() { } - verifyRemove(options) { + verifyRemove() { } + /** + * Delete a public key document and its corresponding user id documents. + * @param {String} options.keyid The key id + * @yield {undefined} + */ + *remove(options) { + // remove key document + yield this._mongo.remove({ _id:options.keyid }, DB_TYPE); + // remove matching user id documents + yield this._userid.remove({ keyid:options.keyid }); + } + } module.exports = PublicKey; \ No newline at end of file diff --git a/src/ctrl/user-id.js b/src/ctrl/user-id.js new file mode 100644 index 0000000..0879680 --- /dev/null +++ b/src/ctrl/user-id.js @@ -0,0 +1,114 @@ +/** + * Mailvelope - secure email with OpenPGP encryption for Webmail + * Copyright (C) 2016 Mailvelope GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License version 3 + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +'use strict'; + +/** + * Database documents have the format: + * { + * _id: ObjectID, // randomly generated by MongoDB + * email: "jon@example.com", // the email address in lowercase + * name: "Jon Smith", + * keyid: "02C134D079701934", // id of the public key document in uppercase hex + * nonce: "123e4567-e89b-12d3-a456-426655440000", // verifier used to prove ownership + * verified: true // if the user ID has been verified + * } + */ +const DB_TYPE = 'userid'; + +/** + * A controller that handles User ID queries to the database + */ +class UserId { + + /** + * Create an instance of the controller + * @param {Object} mongo An instance of the MongoDB client + */ + constructor(mongo) { + this._mongo = mongo; + } + + // + // Create/Update + // + + /** + * Store a list of user ids. There can only be one verified user ID for + * an email address at any given time. + * @param {String} options.keyid The public key id + * @param {Array} options.userIds The userIds to persist + * @yield {Array} A list of user ids with generated nonces + */ + *batch(options) { + options.userIds.forEach(u => u.keyid = options.keyid); // set keyid on docs + let r = yield this._mongo.batch(options.userIds, DB_TYPE); + if (r.insertedCount !== options.userIds.length) { + throw new Error('Failed to persist user ids'); + } + } + + // + // Read + // + + /** + * Get a verified user IDs either by key id or email address. + * There can only be one verified user ID for an email address + * at any given time. + * @param {String} options.keyid The public key id + * @param {String} options.userIds A list of user ids to check + * @yield {Object} The verified user ID document + */ + *getVerfied(options) { + let keyid = options.keyid, userIds = options.userIds; + if (keyid) { + // try by key id + let uids = yield this._mongo.list({ keyid }, DB_TYPE); + let verified = uids.find(u => u.verified); + if (verified) { + return verified; + } + } + if (userIds) { + // try by email addresses + for (let uid of userIds) { + let uids = yield this._mongo.list({ email:uid.email }, DB_TYPE); + let verified = uids.find(u => u.verified); + if (verified) { + return verified; + } + } + } + } + + // + // Delete + // + + /** + * Remove all user ids matching a certain query + * @param {String} options.keyid The public key id + * @yield {undefined} + */ + *remove(options) { + yield this._mongo.remove({ keyid:options.keyid }, DB_TYPE); + } + +} + +module.exports = UserId; \ No newline at end of file diff --git a/src/ctrl/util.js b/src/ctrl/util.js new file mode 100644 index 0000000..37c589e --- /dev/null +++ b/src/ctrl/util.js @@ -0,0 +1,109 @@ +/** + * Mailvelope - secure email with OpenPGP encryption for Webmail + * Copyright (C) 2016 Mailvelope GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License version 3 + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +'use strict'; + +const addressparser = require('addressparser'); + +/** + * Checks for a valid string + * @param {} data The input to be checked + * @return {boolean} If data is a string + */ +exports.isString = function(data) { + return typeof data === 'string' || String.prototype.isPrototypeOf(data); +}; + +/** + * Checks for a valid key id which is between 8 and 40 hex chars. + * @param {string} data The key id + * @return {boolean} If the key id if valid + */ +exports.validateKeyId = function(data) { + if (!this.isString(data)) { + return false; + } + return /^[a-fA-F0-9]{8,40}$/.test(data); +}; + +/** + * Checks for a valid email address. + * @param {string} data The email address + * @return {boolean} If the email address if valid + */ +exports.validateAddress = function(data) { + if (!this.isString(data)) { + return false; + } + const re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + return re.test(data); +}; + +/** + * Validate an ascii armored public PGP key block. + * @param {string} data The armored key block + * @return {boolean} If the key is valid + */ +exports.validatePublicKey = function(data) { + if (!this.isString(data)) { + return false; + } + const begin = /-----BEGIN PGP PUBLIC KEY BLOCK-----/; + const end = /-----END PGP PUBLIC KEY BLOCK-----/; + return begin.test(data) && end.test(data); +}; + +/** + * Parse an array of user id string to objects + * @param {Array} userIds A list of user ids strings + * @return {Array} An array of user id objects + */ +exports.parseUserIds = function(userIds) { + let result = []; + userIds.forEach(uid => result = result.concat(addressparser(uid))); + return result.map(u => ({ + email: u.address ? u.address.toLowerCase() : undefined, + name: u.name + })); +}; + +/** + * Deduplicates items in an array + * @param {Array} list The list of items with duplicates + * @return {Array} The list of items without duplicates + */ +exports.deDup = function(list) { + var result = []; + (list || []).forEach(function(i) { + if (result.indexOf(i) === -1) { + result.push(i); + } + }); + return result; +}; + +/** + * Create an error with a custom status attribute e.g. for http codes. + * @param {number} status The error's http status code + * @param {string} message The error message + * @return {Error} The resulting error object + */ +exports.error = function(status, message) { + let err = new Error(message); + err.status = status; + return err; +}; \ No newline at end of file diff --git a/src/dao/email.js b/src/dao/email.js index eb1e30d..4446acf 100644 --- a/src/dao/email.js +++ b/src/dao/email.js @@ -22,8 +22,31 @@ */ class Email { - send(options) { + /** + * Create an instance of the email object. + * @param {Object} mailer An instance of nodemailer + */ + constructor(mailer) { + this._mailer = mailer; + } + + /** + * Send the verification email to the user to verify email address + * ownership. If the primary email address is provided, only one email + * will be sent out. Otherwise all of the PGP key's user IDs will be + * verified, resulting in an email sent per user ID. + * @param {Array} options.userIds The user id documents containing the nonces + * @param {Array} options.primaryEmail (optional) The user's primary email address + * @yield {undefined} + */ + sendVerification() { + return Promise.resolve(); + } + + send() { } -} \ No newline at end of file +} + +module.exports = Email; \ No newline at end of file diff --git a/src/dao/mongo.js b/src/dao/mongo.js index 0b9b673..2463a22 100644 --- a/src/dao/mongo.js +++ b/src/dao/mongo.js @@ -26,20 +26,17 @@ class Mongo { /** * Create an instance of the MongoDB client. - * @param {String} options.uri The mongodb uri - * @param {String} options.user The databse user - * @param {String} options.password The database user's password - * @param {String} options.type (optional) The default collection type to use e.g. 'publickey' - * @return {undefined} + * @param {String} options.uri The mongodb uri + * @param {String} options.user The databse user + * @param {String} options.password The database user's password */ constructor(options) { this._uri = 'mongodb://' + options.user + ':' + options.password + '@' + options.uri; - this._type = options.type; } /** * Initializes the database client by connecting to the MongoDB. - * @return {undefined} + * @yield {undefined} */ *connect() { this._db = yield MongoClient.connect(this._uri); @@ -47,7 +44,7 @@ class Mongo { /** * Cleanup by closing the connection to the database. - * @return {undefined} + * @yield {undefined} */ disconnect() { return this._db.close(); @@ -55,67 +52,78 @@ class Mongo { /** * Inserts a single document. - * @param {Object} document Inserts a single documents - * @param {String} type (optional) The collection to use e.g. 'publickey' - * @return {Object} The operation result + * @param {Object} document Inserts a single document + * @param {String} type The collection to use e.g. 'publickey' + * @yield {Object} The operation result */ create(document, type) { - let col = this._db.collection(type || this._type); + let col = this._db.collection(type); return col.insertOne(document); } + /** + * Inserts a list of documents. + * @param {Array} documents Inserts a list of documents + * @param {String} type The collection to use e.g. 'publickey' + * @yield {Object} The operation result + */ + batch(documents, type) { + let col = this._db.collection(type); + return col.insertMany(documents); + } + /** * Update a single document. - * @param {Object} query The query e.g. { _id:'0' } - * @param {Object} diff The attributes to change/set e.g. { foo:'bar' } - * @param {String} type (optional) The collection to use e.g. 'publickey' - * @return {Object} The operation result + * @param {Object} query The query e.g. { _id:'0' } + * @param {Object} diff The attributes to change/set e.g. { foo:'bar' } + * @param {String} type The collection to use e.g. 'publickey' + * @yield {Object} The operation result */ update(query, diff, type) { - let col = this._db.collection(type || this._type); + let col = this._db.collection(type); return col.updateOne(query, { $set:diff }); } /** * Read a single document. - * @param {Object} query The query e.g. { _id:'0' } - * @param {String} type (optional) The collection to use e.g. 'publickey' - * @return {Object} The document object + * @param {Object} query The query e.g. { _id:'0' } + * @param {String} type The collection to use e.g. 'publickey' + * @yield {Object} The document object */ get(query, type) { - let col = this._db.collection(type || this._type); + let col = this._db.collection(type); return col.findOne(query); } /** * Read multiple documents at once. - * @param {Object} query The query e.g. { foo:'bar' } - * @param {String} type (optional) The collection to use e.g. 'publickey' - * @return {Array} An array of document objects + * @param {Object} query The query e.g. { foo:'bar' } + * @param {String} type The collection to use e.g. 'publickey' + * @yield {Array} An array of document objects */ list(query, type) { - let col = this._db.collection(type || this._type); + let col = this._db.collection(type); return col.find(query).toArray(); } /** - * Delete a single document. - * @param {Object} query The query e.g. { _id:'0' } - * @param {String} type (optional) The collection to use e.g. 'publickey' - * @return {Object} The document object + * Delete all documents matching a query. + * @param {Object} query The query e.g. { _id:'0' } + * @param {String} type The collection to use e.g. 'publickey' + * @yield {Object} The operation result */ remove(query, type) { - let col = this._db.collection(type || this._type); - return col.deleteOne(query); + let col = this._db.collection(type); + return col.deleteMany(query); } /** * Clear all documents of a collection. - * @param {String} type (optional) The collection to use e.g. 'publickey' - * @return {Object} The operation result + * @param {String} type The collection to use e.g. 'publickey' + * @yield {Object} The operation result */ clear(type) { - let col = this._db.collection(type || this._type); + let col = this._db.collection(type); return col.deleteMany({}); } diff --git a/src/routes/hkp.js b/src/routes/hkp.js index e3bae3e..81f0874 100644 --- a/src/routes/hkp.js +++ b/src/routes/hkp.js @@ -17,6 +17,9 @@ 'use strict'; +const parse = require('co-body'); +const util = require('../ctrl/util'); + /** * An implementation of the OpenPGP HTTP Keyserver Protocol (HKP) * See https://tools.ietf.org/html/draft-shaw-openpgp-hkp-00 @@ -36,8 +39,11 @@ class HKP { * @param {Object} ctx The koa request/response context */ *add(ctx) { - ctx.throw(501, 'Not implemented!'); - yield; + let body = yield parse.form(ctx, { limit: '1mb' }); + if (!util.validatePublicKey(body.keytext)) { + ctx.throw(400, 'Invalid request!'); + } + yield this._publicKey.put({ publicKeyArmored:body.keytext }); } /** @@ -46,20 +52,9 @@ class HKP { */ *lookup(ctx) { let params = this.parseQueryString(ctx); - if (!params) { - return; // invalid request - } - let key = yield this._publicKey.get(params); - if (key) { - ctx.body = key.publicKeyArmored; - if (params.mr) { - this.setGetMRHEaders(ctx); - } - } else { - ctx.status = 404; - ctx.body = 'Not found!'; - } + this.setGetHeaders(ctx, params); + ctx.body = key.publicKeyArmored; } /** @@ -74,33 +69,20 @@ class HKP { mr: ctx.query.options === 'mr' // machine readable }; if (this.checkId(ctx.query.search)) { - params._id = ctx.query.search.replace(/^0x/, ''); - } else if(this.checkEmail(ctx.query.search)) { + params.keyid = ctx.query.search.replace(/^0x/, ''); + } else if(util.validateAddress(ctx.query.search)) { params.email = ctx.query.search; } if (params.op !== 'get') { - ctx.status = 501; - ctx.body = 'Not implemented!'; - return; - } else if (!params._id && !params.email) { - ctx.status = 400; - ctx.body = 'Invalid request!'; - return; + ctx.throw(501, 'Not implemented!'); + } else if (!params.keyid && !params.email) { + ctx.throw(400, 'Invalid request!'); } return params; } - /** - * Checks for a valid email address. - * @param {String} address The email address - * @return {Boolean} If the email address if valid - */ - checkEmail(address) { - return /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/.test(address); - } - /** * Checks for a valid key id in the query string. A key must be prepended * with '0x' and can be between 8 and 40 hex characters long. @@ -108,16 +90,22 @@ class HKP { * @return {Boolean} If the key id is valid */ checkId(keyid) { + if (!util.isString(keyid)) { + return false; + } return /^0x[a-fA-F0-9]{8,40}$/.test(keyid); } /** * Set HTTP headers for a GET requests with 'mr' (machine readable) options. - * @param {Object} ctx The koa request/response context + * @param {Object} ctx The koa request/response context + * @param {Object} params The parsed query string parameters */ - setGetMRHEaders(ctx) { - ctx.set('Content-Type', 'application/pgp-keys; charset=UTF-8'); - ctx.set('Content-Disposition', 'attachment; filename=openpgpkey.asc'); + setGetHeaders(ctx, params) { + if (params.mr) { + ctx.set('Content-Type', 'application/pgp-keys; charset=UTF-8'); + ctx.set('Content-Disposition', 'attachment; filename=openpgpkey.asc'); + } } } diff --git a/src/routes/rest.js b/src/routes/rest.js index d42299b..f30c0d6 100644 --- a/src/routes/rest.js +++ b/src/routes/rest.js @@ -17,18 +17,37 @@ 'use strict'; +const parse = require('co-body'); +const util = require('../ctrl/util'); + /** * The REST api to provide additional functionality on top of HKP */ class REST { + /** + * Create an instance of the REST server + * @param {Object} publicKey An instance of the public key controller + */ constructor(publicKey) { this._publicKey = publicKey; } + // + // Create/Update + // + + /** + * Public key upload via http POST + * @param {Object} ctx The koa request/response context + */ *create(ctx) { - ctx.throw(501, 'Not implemented!'); - yield; + let pk = yield parse.json(ctx, { limit: '1mb' }); + if ((pk.primaryEmail && !util.validateAddress(pk.primaryEmail)) || + !util.validatePublicKey(pk.publicKeyArmored)) { + ctx.throw(400, 'Invalid request!'); + } + yield this._publicKey(pk); } *verify(ctx) { @@ -36,11 +55,38 @@ class REST { yield; } + // + // Read + // + + /** + * Public key fetch via http GET + * @param {Object} ctx The koa request/response context + */ *read(ctx) { - ctx.throw(501, 'Not implemented!'); - yield; + let q = { keyid:ctx.query.keyid, email:ctx.query.email }; + if (!util.validateKeyId(q.keyid) && !util.validateAddress(q.email)) { + ctx.throw(400, 'Invalid request!'); + } + ctx.body = yield this._publicKey.get(q); } + /** + * Public key fetch via http GET (shorthand link for sharing) + * @param {Object} ctx The koa request/response context + */ + *share(ctx) { + let q = { email:ctx.params.email }; + if (!util.validateAddress(q.email)) { + ctx.throw(400, 'Invalid request!'); + } + ctx.body = (yield this._publicKey.get(q)).publicKeyArmored; + } + + // + // Delete + // + *remove(ctx) { ctx.throw(501, 'Not implemented!'); yield; diff --git a/src/worker.js b/src/worker.js index 52e70bf..93a7215 100644 --- a/src/worker.js +++ b/src/worker.js @@ -18,16 +18,20 @@ 'use strict'; const co = require('co'); -const fs = require('fs'); 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 Mongo = require('./dao/mongo'); +const Email = require('./dao/email'); +const UserId = require('./ctrl/user-id'); const PublicKey = require('./ctrl/public-key'); const HKP = require('./routes/hkp'); const REST = require('./routes/rest'); -let mongo, publicKey, hkp, rest; +let mongo, email, userId, publicKey, hkp, rest; // // Configure koa HTTP server @@ -42,25 +46,25 @@ router.get('/pks/lookup', function *() { // ?op=get&search=0x1234567890123456 }); // REST api routes -router.post('/api/key', function *() { // no query params +router.post('/api/v1/key', function *() { // { publicKeyArmored, primaryEmail } hint the primary email address yield rest.create(this); }); -router.get('/api/key', function *() { // ?id=keyid OR ?email=email +router.get('/api/v1/key', function *() { // ?id=keyid OR ?email=email yield rest.read(this); }); -router.del('/api/key', function *() { // ?id=keyid OR ?email=email +router.del('/api/v1/key', function *() { // ?id=keyid OR ?email=email yield rest.remove(this); }); // links for verification and sharing -router.get('/api/verify', function *() { // ?id=keyid&nonce=nonce +router.get('/api/v1/verify', function *() { // ?id=keyid&nonce=nonce yield rest.verify(this); }); -router.get('/api/verifyRemove', function *() { // ?id=keyid&nonce=nonce +router.get('/api/v1/verifyRemove', function *() { // ?id=keyid&nonce=nonce yield rest.verifyRemove(this); }); router.get('/:email', function *() { // shorthand link for sharing - yield rest.read(this); + yield rest.share(this); }); // Set HTTP response headers @@ -76,7 +80,16 @@ app.use(function *(next) { app.use(router.routes()); app.use(router.allowedMethods()); -app.on('error', (err, ctx) => log.error('worker', 'Unknown server error', err, ctx)); + +app.on('error', (error, ctx) => { + if (error.status) { + ctx.status = error.status; + ctx.body = error.message; + log.verbose('worker', 'Request faild: %s, %s', error.status, error.message); + } else { + log.error('worker', 'Unknown error', error, ctx); + } +}); // // Module initialization @@ -89,14 +102,16 @@ function injectDependencies() { user: process.env.MONGO_USER || credentials.mongoUser, password: process.env.MONGO_PASS || credentials.mongoPass }); - publicKey = new PublicKey(mongo); + email = new Email(nodemailer); + userId = new UserId(mongo); + publicKey = new PublicKey(openpgp, mongo, email, userId); hkp = new HKP(publicKey); rest = new REST(publicKey); } function readCredentials() { try { - return JSON.parse(fs.readFileSync(__dirname + '/../credentials.json')); + return require('../credentials.json'); } catch(e) { log.info('worker', 'No credentials.json found ... using environment vars.'); } @@ -106,10 +121,17 @@ function readCredentials() { // Start app ... connect to the database and start listening // -co(function *() { +if (!global.testing) { // don't automatically start server in tests + co(function *() { + let app = yield init(); + app.listen(config.server.port); + }).catch(err => log.error('worker', 'Initialization failed!', err)); +} +function *init() { injectDependencies(); yield mongo.connect(); - app.listen(process.env.PORT || 8888); + return app; +} -}).catch(err => log.error('worker', 'Initialization failed!', err)); \ No newline at end of file +module.exports = init; \ No newline at end of file diff --git a/test/integration/mongo-test.js b/test/integration/mongo-test.js index 2542e7c..0dba27f 100644 --- a/test/integration/mongo-test.js +++ b/test/integration/mongo-test.js @@ -2,62 +2,70 @@ require('co-mocha')(require('mocha')); // monkey patch mocha for generators -const Mongo = require('../../src/dao/mongo'), - expect = require('chai').expect, - fs = require('fs'); +const log = require('npmlog'); +const Mongo = require('../../src/dao/mongo'); +const expect = require('chai').expect; describe('Mongo Integration Tests', function() { this.timeout(20000); - const defaultType = 'apple'; - const secondaryType = 'orange'; + const DB_TYPE = 'apple'; let mongo; before(function *() { let credentials; try { - credentials = JSON.parse(fs.readFileSync(__dirname + '/../../credentials.json')); - } catch(e) {} + credentials = require('../../credentials.json'); + } catch(e) { + log.info('mongo-test', 'No credentials.json found ... using environment vars.'); + } mongo = new Mongo({ uri: process.env.MONGO_URI || credentials.mongoUri, user: process.env.MONGO_USER || credentials.mongoUser, - password: process.env.MONGO_PASS || credentials.mongoPass, - type: defaultType + password: process.env.MONGO_PASS || credentials.mongoPass }); yield mongo.connect(); }); beforeEach(function *() { - yield mongo.clear(); - yield mongo.clear(secondaryType); + yield mongo.clear(DB_TYPE); }); afterEach(function() {}); after(function *() { - yield mongo.clear(); - yield mongo.clear(secondaryType); + yield mongo.clear(DB_TYPE); yield mongo.disconnect(); }); describe("create", function() { it('should insert a document', function *() { - let r = yield mongo.create({ _id:'0' }); - expect(r.insertedCount).to.equal(1); - }); - - it('should insert a document with a type', function *() { - let r = yield mongo.create({ _id:'0' }); - expect(r.insertedCount).to.equal(1); - r = yield mongo.create({ _id:'0' }, secondaryType); + let r = yield mongo.create({ _id:'0' }, DB_TYPE); expect(r.insertedCount).to.equal(1); }); it('should fail if two with the same ID are inserted', function *() { - let r = yield mongo.create({ _id:'0' }); + let r = yield mongo.create({ _id:'0' }, DB_TYPE); expect(r.insertedCount).to.equal(1); try { - r = yield mongo.create({ _id:'0' }); + r = yield mongo.create({ _id:'0' }, DB_TYPE); + } catch(e) { + expect(e.message).to.match(/duplicate/); + } + }); + }); + + describe("batch", function() { + it('should insert a document', function *() { + let r = yield mongo.batch([{ _id:'0' }, { _id:'1' }], DB_TYPE); + expect(r.insertedCount).to.equal(2); + }); + + it('should fail if docs with the same ID are inserted', function *() { + let r = yield mongo.batch([{ _id:'0' }, { _id:'1' }], DB_TYPE); + expect(r.insertedCount).to.equal(2); + try { + r = yield mongo.batch([{ _id:'0' }, { _id:'1' }], DB_TYPE); } catch(e) { expect(e.message).to.match(/duplicate/); } @@ -66,64 +74,35 @@ describe('Mongo Integration Tests', function() { describe("update", function() { it('should update a document', function *() { - let r = yield mongo.create({ _id:'0' }); - r = yield mongo.update({ _id:'0' }, { foo:'bar' }); + let r = yield mongo.create({ _id:'0' }, DB_TYPE); + r = yield mongo.update({ _id:'0' }, { foo:'bar' }, DB_TYPE); expect(r.modifiedCount).to.equal(1); - r = yield mongo.get({ _id:'0' }); - expect(r.foo).to.equal('bar'); - }); - - it('should update a document with a type', function *() { - let r = yield mongo.create({ _id:'0' }, secondaryType); - r = yield mongo.update({ _id:'0' }, { foo:'bar' }, secondaryType); - expect(r.modifiedCount).to.equal(1); - r = yield mongo.get({ _id:'0' }, secondaryType); + r = yield mongo.get({ _id:'0' }, DB_TYPE); expect(r.foo).to.equal('bar'); }); }); describe("get", function() { it('should get a document', function *() { - let r = yield mongo.create({ _id:'0' }); - r = yield mongo.get({ _id:'0' }); - expect(r).to.exist; - }); - - it('should get a document with a type', function *() { - let r = yield mongo.create({ _id:'0' }, secondaryType); - r = yield mongo.get({ _id:'0' }, secondaryType); + let r = yield mongo.create({ _id:'0' }, DB_TYPE); + r = yield mongo.get({ _id:'0' }, DB_TYPE); expect(r).to.exist; }); }); describe("list", function() { it('should list documents', function *() { - let r = yield mongo.create({ _id:'0', foo:'bar' }); - r = yield mongo.create({ _id:'1', foo:'bar' }); - r = yield mongo.list({ foo:'bar' }); - expect(r).to.deep.equal([{ _id:'0', foo:'bar' }, { _id:'1', foo:'bar' }]); - }); - - it('should list documents with a type', function *() { - let r = yield mongo.create({ _id:'0', foo:'bar' }, secondaryType); - r = yield mongo.create({ _id:'1', foo:'bar' }, secondaryType); - r = yield mongo.list({ foo:'bar' }, secondaryType); - expect(r).to.deep.equal([{ _id:'0', foo:'bar' }, { _id:'1', foo:'bar' }]); + let r = yield mongo.batch([{ _id:'0', foo:'bar' }, { _id:'1', foo:'bar' }], DB_TYPE); + r = yield mongo.list({ foo:'bar' }, DB_TYPE); + expect(r).to.deep.equal([{ _id:'0', foo:'bar' }, { _id:'1', foo:'bar' }], DB_TYPE); }); }); describe("remove", function() { it('should remove a document', function *() { - let r = yield mongo.create({ _id:'0' }); - r = yield mongo.remove({ _id:'0' }); - r = yield mongo.get({ _id:'0' }); - expect(r).to.not.exist; - }); - - it('should remove a document with a type', function *() { - let r = yield mongo.create({ _id:'0' }, secondaryType); - r = yield mongo.remove({ _id:'0' }, secondaryType); - r = yield mongo.get({ _id:'0' }, secondaryType); + let r = yield mongo.create({ _id:'0' }, DB_TYPE); + r = yield mongo.remove({ _id:'0' }, DB_TYPE); + r = yield mongo.get({ _id:'0' }, DB_TYPE); expect(r).to.not.exist; }); }); diff --git a/test/integration/worker-test.js b/test/integration/worker-test.js new file mode 100644 index 0000000..05201ea --- /dev/null +++ b/test/integration/worker-test.js @@ -0,0 +1,69 @@ +'use strict'; + +require('co-mocha')(require('mocha')); // monkey patch mocha for generators + +const request = require('supertest'); +const fs = require('fs'); + +describe.skip('Koa HTTP Server (worker) Integration Tests', function() { + this.timeout(20000); + + let app, pgpKey1; + + before(function *() { + pgpKey1 = fs.readFileSync(__dirname + '/../key1.asc', 'utf8'); + global.testing = true; + let init = require('../../src/worker'); + app = yield init(); + }); + + beforeEach(function () {}); + + afterEach(function() {}); + + after(function () {}); + + describe('REST api', function() { + describe('POST /api/v1/key', function() { + it('should return 400 for an invalid body', function (done) { + request(app.listen()) + .post('/api/v1/key') + .send({ foo: 'bar' }) + .expect(400) + .end(done); + }); + }); + }); + + describe('HKP api', function() { + describe('GET /pks/add', function() { + it('should return 200 for a valid request', function (done) { + request(app.listen()) + .get('/pks/lookup?op=get&search=0xDBC0B3D92B1B86E9') + .expect(200) + .end(done); + }); + }); + + describe('POST /pks/add', function() { + it('should return 400 for an invalid body', function (done) { + request(app.listen()) + .post('/pks/add') + .type('form') + .send('keytext=asdf') + .expect(400) + .end(done); + }); + + it('should return 200 for a valid PGP key', function (done) { + request(app.listen()) + .post('/pks/add') + .type('form') + .send('keytext=' + encodeURIComponent(pgpKey1)) + .expect(200) + .end(done); + }); + }); + }); + +}); \ No newline at end of file diff --git a/test/key1.asc b/test/key1.asc new file mode 100644 index 0000000..dd0ace0 --- /dev/null +++ b/test/key1.asc @@ -0,0 +1,32 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: OpenPGP.js v1.4.1 + +xsBNBFZ+SREBCADFXPrLlyXIFHmBcRFEs2nwzID8X3+YmUOvY55+TJbyeggt +Ccgd21JiBNuL3yilZ2HmQbjzmJvuLFIKCBSmGeg3LU3QhcoSdHZu+NtiimuJ +/CruUFD0JJJn0LdV+4R9nlsoUaNtmTfmgS0ErfcICTmzASgjIwk1bKwFN6qh +L+LAVNOU7QL5Jk/6MDUVIU/6sijqGAANAy3aDnFx0x0e0A0QUVdXZU4TUV42 +wu31rN39ZhgfHwvSEDZsljh2UvoYgS9uFfmO2CxXhoSsp3LKbwP9Os9atlOr +7vNOO+nL6GXuBFqNNNycpajiIMBLm6XTeY4ci+EaUX54q7DFUJfamBS3ABEB +AAHNM3NhZmV3aXRobWUgdGVzdHVzZXIgPHNhZmV3aXRobWUudGVzdHVzZXJA +Z21haWwuY29tPsLAcgQQAQgAJgUCVn5JEgYLCQgHAwIJENvAs9krG4bpBBUI +AgoDFgIBAhsDAh4BAAAr6Qf/WWCYCBL2Izau5S+H5zZtCk5Rde51pGw1fsMd +gMlZK7knMUSlfjIEx6C/y5uQRRJc6f44p7B609mCGwW+f91wpAGq4d6O3+BV +25GDj25UvN8dBW42cufLG7tTSXkDXQNhaWEF15yD7aDOdaWy6OpD66xiR2Rs +vT9BG5la4vIVwQxcMeTg62axTe/uu7IwcxQr1zT9nNvw5lmrF68YqTVl4ArM +axV9yMsbTmjYvS5LmD2vSzRi/OJzfIMYAiTbkSgoF91lFp0rAgq485MEOBjF +T4CnwCVHT8BOjeBi7JnKDb3JN7HGYHX1ZwuaiYJDtkbqHKrTLSmtJJUTJBRs +p14uqM7ATQRWfkkRAQgA2l9OZ2doMLKNhi6JC1Qd1iBWrmMAflbuOstoRz76 +C/++VUlVeT7tuOiVJtYgxc1qRgIZEwzZIpM5/p25lX6mrXkgUJd7w8EYbTqa +J9h5jeontZaGHciVRAWyUy35PMevXTt4pKXQvzGS3jXK46ICE2/rxa02sE1N +S1kHCMQnWh89uMpE7sIG2s8QPOYVBHk88hf4M6jGDT2f2pKFwWuJ51z9IZul +wF/my6/rSBkXqhckJCOaq4H1F6iQCV6R0NmEMMe+UYz784mcw9B5JDqYoTux +3uCPEUeb7kW3M6IdPo2OSEEuvukdQvDmt3jKjnCk5RPKYZYFl9yqFVAf0gbp +EwARAQABwsBfBBgBCAATBQJWfkkTCRDbwLPZKxuG6QIbDAAAJyQH/icIJUhb +AuFntIB/nuX52kubnaXLlQc/erIg4y2aN92+g9ULv2myw6lf+kt5IHQtroR1 +MVFSZgHVwSIhrwZqbaZTvi7VZq5NYDjRL1mda+rodhyNQEVM+8Q4XZh7yR8h +TNZn6OsENP1ctxs4J4T/jJL0mdhG/aCkbO4DICAEToViWOmUOpQBJwUI41Wh +qSeCLRV510QasWZVe0o86yCB11gxrg/+xd5XN6Za/pTtz+4KeD3m8ssygdNS +woY7ieO647qE7GagQdWP+4BIYPeEqnRTqyTMxpSivlal2IcEw/Fi0xM97+ER +FtBVBq+eZC88+gSiwcmxsB8s3rMPQhJ6Q0Y= +=/POi +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file