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

View File

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

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

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

View File

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

View File

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

View File

@@ -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));
module.exports = init;