First working prototype of the keyserver

This commit is contained in:
Tankred Hase 2016-05-27 19:57:48 +02:00
parent 439ab77422
commit 2d07c34060
15 changed files with 664 additions and 167 deletions

10
config/default.js Normal file
View File

@ -0,0 +1,10 @@
'use strict';
module.exports = {
server: {
port: process.env.PORT || 8888,
},
log: {
level: "silly"
}
};

7
config/production.js Normal file
View File

@ -0,0 +1,7 @@
'use strict';
module.exports = {
log: {
level: "error"
}
};

View File

@ -13,11 +13,15 @@
"test": "grunt test" "test": "grunt test"
}, },
"dependencies": { "dependencies": {
"addressparser": "^1.0.1",
"co": "^4.6.0", "co": "^4.6.0",
"co-body": "^4.2.0",
"koa": "^1.2.0", "koa": "^1.2.0",
"koa-router": "^5.4.0", "koa-router": "^5.4.0",
"mongodb": "^2.1.20", "mongodb": "^2.1.20",
"npmlog": "^2.0.4" "nodemailer": "^2.4.2",
"npmlog": "^2.0.4",
"openpgp": "^2.3.0"
}, },
"devDependencies": { "devDependencies": {
"chai": "^3.5.0", "chai": "^3.5.0",
@ -27,6 +31,7 @@
"grunt-jscs": "^2.8.0", "grunt-jscs": "^2.8.0",
"grunt-mocha-test": "^0.12.7", "grunt-mocha-test": "^0.12.7",
"mocha": "^2.5.3", "mocha": "^2.5.3",
"sinon": "^1.17.4" "sinon": "^1.17.4",
"supertest": "^1.2.0"
} }
} }

View File

@ -19,8 +19,11 @@
const cluster = require('cluster'); const cluster = require('cluster');
const numCPUs = require('os').cpus().length; const numCPUs = require('os').cpus().length;
const config = require('config');
const log = require('npmlog'); 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 // Start worker cluster depending on number of CPUs
// //

View File

@ -17,11 +17,13 @@
'use strict'; 'use strict';
const log = require('npmlog');
const util = require('./util');
/** /**
* Database documents have the format: * Database documents have the format:
* { * {
* _id: "02C134D079701934", // the 16 byte key id * _id: "02C134D079701934", // the 16 byte key id in uppercase hex
* email: "jon@example.com", // the primary and verified email address
* publicKeyArmored: "-----BEGIN PGP PUBLIC KEY BLOCK----- ... -----END PGP PUBLIC KEY BLOCK-----" * publicKeyArmored: "-----BEGIN PGP PUBLIC KEY BLOCK----- ... -----END PGP PUBLIC KEY BLOCK-----"
* } * }
*/ */
@ -34,21 +36,73 @@ class PublicKey {
/** /**
* Create an instance of the controller * Create an instance of the controller
* @param {Object} openpgp An instance of OpenPGP.js
* @param {Object} mongo An instance of the MongoDB client * @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._mongo = mongo;
this._email = email;
this._userid = userid;
} }
// //
// Create/Update // 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 // 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 // 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; module.exports = PublicKey;

114
src/ctrl/user-id.js Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
*/
'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;

109
src/ctrl/util.js Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
*/
'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;
};

View File

@ -22,8 +22,31 @@
*/ */
class Email { 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() {
} }
} }
module.exports = Email;

View File

@ -29,17 +29,14 @@ class Mongo {
* @param {String} options.uri The mongodb uri * @param {String} options.uri The mongodb uri
* @param {String} options.user The databse user * @param {String} options.user The databse user
* @param {String} options.password The database user's password * @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}
*/ */
constructor(options) { constructor(options) {
this._uri = 'mongodb://' + options.user + ':' + options.password + '@' + options.uri; this._uri = 'mongodb://' + options.user + ':' + options.password + '@' + options.uri;
this._type = options.type;
} }
/** /**
* Initializes the database client by connecting to the MongoDB. * Initializes the database client by connecting to the MongoDB.
* @return {undefined} * @yield {undefined}
*/ */
*connect() { *connect() {
this._db = yield MongoClient.connect(this._uri); this._db = yield MongoClient.connect(this._uri);
@ -47,7 +44,7 @@ class Mongo {
/** /**
* Cleanup by closing the connection to the database. * Cleanup by closing the connection to the database.
* @return {undefined} * @yield {undefined}
*/ */
disconnect() { disconnect() {
return this._db.close(); return this._db.close();
@ -55,67 +52,78 @@ class Mongo {
/** /**
* Inserts a single document. * Inserts a single document.
* @param {Object} document Inserts a single documents * @param {Object} document Inserts a single document
* @param {String} type (optional) The collection to use e.g. 'publickey' * @param {String} type The collection to use e.g. 'publickey'
* @return {Object} The operation result * @yield {Object} The operation result
*/ */
create(document, type) { create(document, type) {
let col = this._db.collection(type || this._type); let col = this._db.collection(type);
return col.insertOne(document); 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. * Update a single document.
* @param {Object} query The query e.g. { _id:'0' } * @param {Object} query The query e.g. { _id:'0' }
* @param {Object} diff The attributes to change/set e.g. { foo:'bar' } * @param {Object} diff The attributes to change/set e.g. { foo:'bar' }
* @param {String} type (optional) The collection to use e.g. 'publickey' * @param {String} type The collection to use e.g. 'publickey'
* @return {Object} The operation result * @yield {Object} The operation result
*/ */
update(query, diff, type) { update(query, diff, type) {
let col = this._db.collection(type || this._type); let col = this._db.collection(type);
return col.updateOne(query, { $set:diff }); return col.updateOne(query, { $set:diff });
} }
/** /**
* Read a single document. * Read a single document.
* @param {Object} query The query e.g. { _id:'0' } * @param {Object} query The query e.g. { _id:'0' }
* @param {String} type (optional) The collection to use e.g. 'publickey' * @param {String} type The collection to use e.g. 'publickey'
* @return {Object} The document object * @yield {Object} The document object
*/ */
get(query, type) { get(query, type) {
let col = this._db.collection(type || this._type); let col = this._db.collection(type);
return col.findOne(query); return col.findOne(query);
} }
/** /**
* Read multiple documents at once. * Read multiple documents at once.
* @param {Object} query The query e.g. { foo:'bar' } * @param {Object} query The query e.g. { foo:'bar' }
* @param {String} type (optional) The collection to use e.g. 'publickey' * @param {String} type The collection to use e.g. 'publickey'
* @return {Array} An array of document objects * @yield {Array} An array of document objects
*/ */
list(query, type) { list(query, type) {
let col = this._db.collection(type || this._type); let col = this._db.collection(type);
return col.find(query).toArray(); return col.find(query).toArray();
} }
/** /**
* Delete a single document. * Delete all documents matching a query.
* @param {Object} query The query e.g. { _id:'0' } * @param {Object} query The query e.g. { _id:'0' }
* @param {String} type (optional) The collection to use e.g. 'publickey' * @param {String} type The collection to use e.g. 'publickey'
* @return {Object} The document object * @yield {Object} The operation result
*/ */
remove(query, type) { remove(query, type) {
let col = this._db.collection(type || this._type); let col = this._db.collection(type);
return col.deleteOne(query); return col.deleteMany(query);
} }
/** /**
* Clear all documents of a collection. * Clear all documents of a collection.
* @param {String} type (optional) The collection to use e.g. 'publickey' * @param {String} type The collection to use e.g. 'publickey'
* @return {Object} The operation result * @yield {Object} The operation result
*/ */
clear(type) { clear(type) {
let col = this._db.collection(type || this._type); let col = this._db.collection(type);
return col.deleteMany({}); return col.deleteMany({});
} }

View File

@ -17,6 +17,9 @@
'use strict'; 'use strict';
const parse = require('co-body');
const util = require('../ctrl/util');
/** /**
* An implementation of the OpenPGP HTTP Keyserver Protocol (HKP) * An implementation of the OpenPGP HTTP Keyserver Protocol (HKP)
* See https://tools.ietf.org/html/draft-shaw-openpgp-hkp-00 * 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 * @param {Object} ctx The koa request/response context
*/ */
*add(ctx) { *add(ctx) {
ctx.throw(501, 'Not implemented!'); let body = yield parse.form(ctx, { limit: '1mb' });
yield; 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) { *lookup(ctx) {
let params = this.parseQueryString(ctx); let params = this.parseQueryString(ctx);
if (!params) {
return; // invalid request
}
let key = yield this._publicKey.get(params); let key = yield this._publicKey.get(params);
if (key) { this.setGetHeaders(ctx, params);
ctx.body = key.publicKeyArmored; ctx.body = key.publicKeyArmored;
if (params.mr) {
this.setGetMRHEaders(ctx);
}
} else {
ctx.status = 404;
ctx.body = 'Not found!';
}
} }
/** /**
@ -74,33 +69,20 @@ class HKP {
mr: ctx.query.options === 'mr' // machine readable mr: ctx.query.options === 'mr' // machine readable
}; };
if (this.checkId(ctx.query.search)) { if (this.checkId(ctx.query.search)) {
params._id = ctx.query.search.replace(/^0x/, ''); params.keyid = ctx.query.search.replace(/^0x/, '');
} else if(this.checkEmail(ctx.query.search)) { } else if(util.validateAddress(ctx.query.search)) {
params.email = ctx.query.search; params.email = ctx.query.search;
} }
if (params.op !== 'get') { if (params.op !== 'get') {
ctx.status = 501; ctx.throw(501, 'Not implemented!');
ctx.body = 'Not implemented!'; } else if (!params.keyid && !params.email) {
return; ctx.throw(400, 'Invalid request!');
} else if (!params._id && !params.email) {
ctx.status = 400;
ctx.body = 'Invalid request!';
return;
} }
return params; 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 * 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. * with '0x' and can be between 8 and 40 hex characters long.
@ -108,17 +90,23 @@ class HKP {
* @return {Boolean} If the key id is valid * @return {Boolean} If the key id is valid
*/ */
checkId(keyid) { checkId(keyid) {
if (!util.isString(keyid)) {
return false;
}
return /^0x[a-fA-F0-9]{8,40}$/.test(keyid); return /^0x[a-fA-F0-9]{8,40}$/.test(keyid);
} }
/** /**
* Set HTTP headers for a GET requests with 'mr' (machine readable) options. * 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) { setGetHeaders(ctx, params) {
if (params.mr) {
ctx.set('Content-Type', 'application/pgp-keys; charset=UTF-8'); ctx.set('Content-Type', 'application/pgp-keys; charset=UTF-8');
ctx.set('Content-Disposition', 'attachment; filename=openpgpkey.asc'); ctx.set('Content-Disposition', 'attachment; filename=openpgpkey.asc');
} }
}
} }

View File

@ -17,18 +17,37 @@
'use strict'; 'use strict';
const parse = require('co-body');
const util = require('../ctrl/util');
/** /**
* The REST api to provide additional functionality on top of HKP * The REST api to provide additional functionality on top of HKP
*/ */
class REST { class REST {
/**
* Create an instance of the REST server
* @param {Object} publicKey An instance of the public key controller
*/
constructor(publicKey) { constructor(publicKey) {
this._publicKey = publicKey; this._publicKey = publicKey;
} }
//
// Create/Update
//
/**
* Public key upload via http POST
* @param {Object} ctx The koa request/response context
*/
*create(ctx) { *create(ctx) {
ctx.throw(501, 'Not implemented!'); let pk = yield parse.json(ctx, { limit: '1mb' });
yield; if ((pk.primaryEmail && !util.validateAddress(pk.primaryEmail)) ||
!util.validatePublicKey(pk.publicKeyArmored)) {
ctx.throw(400, 'Invalid request!');
}
yield this._publicKey(pk);
} }
*verify(ctx) { *verify(ctx) {
@ -36,10 +55,37 @@ class REST {
yield; yield;
} }
//
// Read
//
/**
* Public key fetch via http GET
* @param {Object} ctx The koa request/response context
*/
*read(ctx) { *read(ctx) {
ctx.throw(501, 'Not implemented!'); let q = { keyid:ctx.query.keyid, email:ctx.query.email };
yield; 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) { *remove(ctx) {
ctx.throw(501, 'Not implemented!'); ctx.throw(501, 'Not implemented!');

View File

@ -18,16 +18,20 @@
'use strict'; 'use strict';
const co = require('co'); const co = require('co');
const fs = require('fs');
const app = require('koa')(); const app = require('koa')();
const log = require('npmlog'); const log = require('npmlog');
const config = require('config');
const router = require('koa-router')(); const router = require('koa-router')();
const openpgp = require('openpgp');
const nodemailer = require('nodemailer');
const Mongo = require('./dao/mongo'); const Mongo = require('./dao/mongo');
const Email = require('./dao/email');
const UserId = require('./ctrl/user-id');
const PublicKey = require('./ctrl/public-key'); const PublicKey = require('./ctrl/public-key');
const HKP = require('./routes/hkp'); const HKP = require('./routes/hkp');
const REST = require('./routes/rest'); const REST = require('./routes/rest');
let mongo, publicKey, hkp, rest; let mongo, email, userId, publicKey, hkp, rest;
// //
// Configure koa HTTP server // Configure koa HTTP server
@ -42,25 +46,25 @@ router.get('/pks/lookup', function *() { // ?op=get&search=0x1234567890123456
}); });
// REST api routes // 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); 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); 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); yield rest.remove(this);
}); });
// links for verification and sharing // 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); 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); yield rest.verifyRemove(this);
}); });
router.get('/:email', function *() { // shorthand link for sharing router.get('/:email', function *() { // shorthand link for sharing
yield rest.read(this); yield rest.share(this);
}); });
// Set HTTP response headers // Set HTTP response headers
@ -76,7 +80,16 @@ app.use(function *(next) {
app.use(router.routes()); app.use(router.routes());
app.use(router.allowedMethods()); 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 // Module initialization
@ -89,14 +102,16 @@ function injectDependencies() {
user: process.env.MONGO_USER || credentials.mongoUser, user: process.env.MONGO_USER || credentials.mongoUser,
password: process.env.MONGO_PASS || credentials.mongoPass 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); hkp = new HKP(publicKey);
rest = new REST(publicKey); rest = new REST(publicKey);
} }
function readCredentials() { function readCredentials() {
try { try {
return JSON.parse(fs.readFileSync(__dirname + '/../credentials.json')); return require('../credentials.json');
} catch(e) { } catch(e) {
log.info('worker', 'No credentials.json found ... using environment vars.'); 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 // 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(); injectDependencies();
yield mongo.connect(); yield mongo.connect();
app.listen(process.env.PORT || 8888); return app;
}
}).catch(err => log.error('worker', 'Initialization failed!', err)); module.exports = init;

View File

@ -2,62 +2,70 @@
require('co-mocha')(require('mocha')); // monkey patch mocha for generators require('co-mocha')(require('mocha')); // monkey patch mocha for generators
const Mongo = require('../../src/dao/mongo'), const log = require('npmlog');
expect = require('chai').expect, const Mongo = require('../../src/dao/mongo');
fs = require('fs'); const expect = require('chai').expect;
describe('Mongo Integration Tests', function() { describe('Mongo Integration Tests', function() {
this.timeout(20000); this.timeout(20000);
const defaultType = 'apple'; const DB_TYPE = 'apple';
const secondaryType = 'orange';
let mongo; let mongo;
before(function *() { before(function *() {
let credentials; let credentials;
try { try {
credentials = JSON.parse(fs.readFileSync(__dirname + '/../../credentials.json')); credentials = require('../../credentials.json');
} catch(e) {} } catch(e) {
log.info('mongo-test', 'No credentials.json found ... using environment vars.');
}
mongo = new Mongo({ mongo = new Mongo({
uri: process.env.MONGO_URI || credentials.mongoUri, uri: process.env.MONGO_URI || credentials.mongoUri,
user: process.env.MONGO_USER || credentials.mongoUser, user: process.env.MONGO_USER || credentials.mongoUser,
password: process.env.MONGO_PASS || credentials.mongoPass, password: process.env.MONGO_PASS || credentials.mongoPass
type: defaultType
}); });
yield mongo.connect(); yield mongo.connect();
}); });
beforeEach(function *() { beforeEach(function *() {
yield mongo.clear(); yield mongo.clear(DB_TYPE);
yield mongo.clear(secondaryType);
}); });
afterEach(function() {}); afterEach(function() {});
after(function *() { after(function *() {
yield mongo.clear(); yield mongo.clear(DB_TYPE);
yield mongo.clear(secondaryType);
yield mongo.disconnect(); yield mongo.disconnect();
}); });
describe("create", function() { describe("create", function() {
it('should insert a document', function *() { it('should insert a document', function *() {
let r = yield mongo.create({ _id:'0' }); let r = yield mongo.create({ _id:'0' }, DB_TYPE);
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);
expect(r.insertedCount).to.equal(1); expect(r.insertedCount).to.equal(1);
}); });
it('should fail if two with the same ID are inserted', function *() { 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); expect(r.insertedCount).to.equal(1);
try { 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) { } catch(e) {
expect(e.message).to.match(/duplicate/); expect(e.message).to.match(/duplicate/);
} }
@ -66,64 +74,35 @@ describe('Mongo Integration Tests', function() {
describe("update", function() { describe("update", function() {
it('should update a document', function *() { it('should update a document', function *() {
let r = yield mongo.create({ _id:'0' }); let r = yield mongo.create({ _id:'0' }, DB_TYPE);
r = yield mongo.update({ _id:'0' }, { foo:'bar' }); r = yield mongo.update({ _id:'0' }, { foo:'bar' }, DB_TYPE);
expect(r.modifiedCount).to.equal(1); expect(r.modifiedCount).to.equal(1);
r = yield mongo.get({ _id:'0' }); r = yield mongo.get({ _id:'0' }, DB_TYPE);
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);
expect(r.foo).to.equal('bar'); expect(r.foo).to.equal('bar');
}); });
}); });
describe("get", function() { describe("get", function() {
it('should get a document', function *() { it('should get a document', function *() {
let r = yield mongo.create({ _id:'0' }); let r = yield mongo.create({ _id:'0' }, DB_TYPE);
r = yield mongo.get({ _id:'0' }); r = yield mongo.get({ _id:'0' }, DB_TYPE);
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);
expect(r).to.exist; expect(r).to.exist;
}); });
}); });
describe("list", function() { describe("list", function() {
it('should list documents', function *() { it('should list documents', function *() {
let r = yield mongo.create({ _id:'0', foo:'bar' }); let r = yield mongo.batch([{ _id:'0', foo:'bar' }, { _id:'1', foo:'bar' }], DB_TYPE);
r = yield mongo.create({ _id:'1', foo:'bar' }); r = yield mongo.list({ foo:'bar' }, DB_TYPE);
r = yield mongo.list({ foo:'bar' }); expect(r).to.deep.equal([{ _id:'0', foo:'bar' }, { _id:'1', foo:'bar' }], DB_TYPE);
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' }]);
}); });
}); });
describe("remove", function() { describe("remove", function() {
it('should remove a document', function *() { it('should remove a document', function *() {
let r = yield mongo.create({ _id:'0' }); let r = yield mongo.create({ _id:'0' }, DB_TYPE);
r = yield mongo.remove({ _id:'0' }); r = yield mongo.remove({ _id:'0' }, DB_TYPE);
r = yield mongo.get({ _id:'0' }); r = yield mongo.get({ _id:'0' }, DB_TYPE);
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);
expect(r).to.not.exist; expect(r).to.not.exist;
}); });
}); });

View File

@ -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);
});
});
});
});

32
test/key1.asc Normal file
View File

@ -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-----