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