initial keyserver implementation

This commit is contained in:
Zachary Carter 2013-02-15 15:31:58 -08:00
Родитель 50006fbb6a
Коммит e608288407
19 изменённых файлов: 970 добавлений и 15 удалений

15
.gitignore поставляемый
Просмотреть файл

@ -1,14 +1 @@
lib-cov
*.seed
*.log
*.csv
*.dat
*.out
*.pid
*.gz
pids
logs
results
npm-debug.log
/node_modules

3
LICENSE Normal file
Просмотреть файл

@ -0,0 +1,3 @@
Mozilla Public License (MPL) v.2
http://www.mozilla.org/MPL/2.0/

Просмотреть файл

@ -1,4 +1,66 @@
picl-keyserver
==============
Key management for PICL users
Key management for PICL users
## API
You can currently create a new user account, add additional devices, and bump the class A key version.
All API calls take a JSON payload of an email address. E.g.:
{
email: bob@example.com
}
Eventuall, they might take an assertion:
{
assertion: <persona generated assertion>
}
### PUT /user/create
Creates a new user account and generates a class A key.
*Returns*:
{
success: true,
kA: <32 random bytes in hex>,
version: 1,
deviceId: <32 random bytes in hex>
}
### PUT /device/create
Registers a new device with the user account.
*Returns*
{
success: true,
kA: <user's current kA>,
version: <kA version>
deviceId: <newly generated deviceId>
}
### PUT /user/get/{deviceId}
Fetches the user's current key.
*Returns*
{
success: true,
kA: <user's current kA>,
version: <kA version>
}
### PUT /user/bump/{deviceId}
This creates a new class A key for the user and bumps the version number.
All devices besides the device that initiated the call will be marked as having
an outdated key.
*Returns*
{
success: true,
kA: <newly generated kA>,
version: <kA version>
}

8
index.js Normal file
Просмотреть файл

@ -0,0 +1,8 @@
#!/usr/bin/env node
var server = require('./server.js');
// Start the server
server.start(function() {
console.log("running on http://" + server.settings.host + ":" + server.settings.port);
});

86
lib/config.js Normal file
Просмотреть файл

@ -0,0 +1,86 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
var convict = require('convict');
const AVAILABLE_BACKENDS = ["memory", "couchbase"];
var conf = module.exports = convict({
env: {
doc: "The current node.js environment",
default: "production",
format: [ "production", "local", "test" ],
env: 'NODE_ENV'
},
public_url: {
format: "url",
// the real url is set by awsbox
default: "http://127.0.0.1:8090"
},
verifier_url: {
doc: "Service used to verify Persona assertions",
format: "url",
default: "https://verifier.login.persona.org/verify",
env: 'VERIFIER_URL'
},
kvstore: {
backend: {
format: AVAILABLE_BACKENDS,
default: "memory",
env: 'KVSTORE_BACKEND'
},
available_backends: {
doc: "List of available key-value stores",
default: AVAILABLE_BACKENDS
}
},
couchbase: {
user: {
default: 'Administrator',
env: 'KVSTORE_USERNAME'
},
password: {
default: 'password',
env: 'KVSTORE_PASSWORD'
},
bucket: {
default: 'picl',
env: 'KVSTORE_BUCKET'
},
hosts: [ "localhost:8091" ]
},
bind_to: {
host: {
doc: "The ip address the server should bind",
default: '127.0.0.1',
format: 'ipaddress',
env: 'IP_ADDRESS'
},
port: {
doc: "The port the server should bind",
default: 8090,
format: 'port',
env: 'PORT'
}
}
});
// handle configuration files. you can specify a CSV list of configuration
// files to process, which will be overlayed in order, in the CONFIG_FILES
// environment variable
if (process.env.CONFIG_FILES) {
var files = process.env.CONFIG_FILES.split(',');
files.forEach(function(file) {
conf.loadFile(file);
});
}
if (conf.get('env') === 'test') {
if (conf.get('kvstore.backend') === 'couchbase') {
conf.set('couchbase.bucket', 'default');
}
}
conf.validate();

71
lib/db.js Normal file
Просмотреть файл

@ -0,0 +1,71 @@
const Hapi = require('hapi');
const kvstore = require('picl-server/lib/kvstore');
const config = require('./config.js');
/* Data is stored using the abstract 'kvstore' interface from picl-server.
* We use a single, shared connection to the store, which is established
* automatically but asynchronously. Thus, there is a little bit of magic
* here to ensure the API functions in this module will wait for a connection
* to be established. Just wrap any such functions with 'waitKV' like so:
*
* var get_tabs = waitKV(function(user, device, cb) {
* // Calls will be delayed until the shared connection is ready.
* kv.get('whatever', function(err, res) {
* // ...do whatever with the result here...
* });
* });
*
*/
var kv = null;
var kvErr = null;
var kvWaitlist = [];
var connectList = [];
function waitKV(func) {
return function() {
// If the kv connection is ready, immediately call the function.
// It can safely use the shared global variable.
if (kv !== null) {
func.apply(this, arguments);
}
// If the connection errored out, immediately call the callback function
// to report the error. Callback is assumed to be the last argument.
else if (kvErr !== null) {
arguments[arguments.length - 1].call(this, kvErr);
}
// Otherwise, we have to wait for the database connection.
// Re-wrap the function so that the above logic will be applied when ready.
else {
kvWaitlist.push(waitKV(func));
}
};
}
kvstore.connect(config.get('kvstore'), function(err, conn) {
if (err) {
kvErr = err;
} else {
kv = conn;
}
while (connectList.length) {
connectList.pop()(kvErr, conn);
}
while (kvWaitlist.length) {
process.nextTick(kvWaitlist.pop());
}
});
function onconnect(cb) {
if (kv || kvErr) {
cb(kvErr, kv);
} else {
connectList.push(cb);
}
}
module.exports = {
wait: waitKV,
onconnect: onconnect
};

26
lib/devices.js Normal file
Просмотреть файл

@ -0,0 +1,26 @@
var util = require('./util');
module.exports = function(db) {
// creates a new device
function create(user, cb) {
util.getDeviceID(function (err, id) {
var device = {
user: user,
latest_version: true,
last_update: +new Date
};
db.set(id, device, function(err) {
cb(err, id);
});
});
}
// returns device info
function get(id, cb) {
db.get(id, cb);
}
return { create: create, get: get };
};

36
lib/helpers.js Normal file
Просмотреть файл

@ -0,0 +1,36 @@
var Hapi = require('Hapi');
var verify = require('picl-server/lib/verify.js');
var config = require('./config.js');
var users = require('./users.js');
// fake auth just returns the email address
exports.email = function(email, next) {
next(email);
};
// verify an assertion and return the email address
exports.verify = function(assertion, audience, next) {
verify(assertion, audience, config.get('verifier_url'),
function(err, result) {
if (err) next(Hapi.Error.badRequest(err));
else next(result.email);
});
};
// retrieve the userId associated with an email address
// TODO add heavy cacheing of this. Changing email should be rare.
exports.userId = function(email, next) {
users.getId(email, function(err, userId) {
if (err) next(Hapi.Error.badRequest(err));
else next(userId);
});
};
// retrieve the meta data for a user
exports.user = function(userId, next) {
users.getUser(userId, function(err, user) {
if (err) next(Hapi.Error.badRequest(err));
else next(user);
});
};

27
lib/prereqs.js Normal file
Просмотреть файл

@ -0,0 +1,27 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// Prerequesites can be included in a route's configuration and will run
// before the route's handler is called. Results are set on
// the request.pre object using the method's name for the property name,
// or otherwise using the value of the "assign" property.
//
// Methods specified as strings are helpers. Check ./helpers.js for
// definitions.
module.exports = {
email: {
method: 'email(payload.email)'
},
assertion: {
method: 'verify(payload.assertion, server.settings.uri)',
assign: 'email'
},
userId: {
method: 'userId(pre.email)'
},
user: {
method: 'user(pre.userId)'
}
};

183
lib/users.js Normal file
Просмотреть файл

@ -0,0 +1,183 @@
var uuid = require('node-uuid');
var util = require('./util.js');
var async = require('async');
var db = require('./db.js');
var devices = require('./devices.js');
// kv will be initialized once the database connection has been established
var kv;
db.onconnect(function(err, conn) { kv = conn; });
/* user account model
*
* user should have account id
* <email>/userid = <userId>
* <userId>/devices = {
* <deviceId> = {
* latestKey: <true/false>
* lastKeyRequest: <time>
* },
* <deviceId> = { },
* <userId>/meta = {
* kA: <kA key>
* kA_version: <kA version>
* }
*
* */
exports.create = db.wait(function(email, cb) {
// generate user id
var userId = uuid.v4();
var metaKey = userId + '/meta';
var devicesKey = userId + '/devices';
var kA, deviceId;
async.waterfall([
// link email to userid
function(cb) {
kv.set(email + '/userid', userId, cb);
},
// get new class A key
util.getKA,
// create user account
function(key, cb) {
kA = key;
kv.set(metaKey, { kA: key, kA_version: 1 }, cb);
},
// get new device id
util.getDeviceId,
// init devices
function(id, cb) {
deviceId = id;
var devices = {};
devices[deviceId] = initDevice();
kv.set(devicesKey, devices, cb);
},
// return data
function(cb) {
cb(null, { kA: kA, deviceId: deviceId, version: 1 });
}
], cb);
});
// This method returns the userId currently associated with an email address.
exports.getId = db.wait(function(email, cb) {
kv.get(email + '/userid', function(err, result) {
cb(err, result && result.value);
});
});
// This method creates a new device for the user
exports.addDevice = db.wait(function(userId, cb) {
var metaKey = userId + '/meta';
var devicesKey = userId + '/devices';
var id;
async.waterfall([
// get new device id
util.getDeviceId,
// get user devices
function(deviceId, cb) {
id = deviceId;
kv.get(devicesKey, cb);
},
// save account
function(devices, cb) {
if (!devices) return cb('UnknownUser');
devices.value[id] = initDevice();
kv.set(devicesKey, devices.value, function(err) { cb(err, id); });
}
], cb);
});
// Whenever a device requests the latest key, we update
// its meta data to show that it's up to date
exports.updateDevice = db.wait(function(userId, deviceId, cb) {
var devicesKey = userId + '/devices';
async.waterfall([
// get device info
function(cb) { kv.get(devicesKey, cb); },
// update devices info and save
function(devices, cb) {
if (!devices) return cb('UnknownUser');
if (!devices.value[deviceId]) return cb('UnknownDevice');
// device has the latest version
devices.value[deviceId].latestVersion = true;
// device's last request was now
devices.value[deviceId].lastKeyRequest = +new Date();
kv.set(devicesKey, devices.value, cb);
}
], cb);
});
// When a device bumps the user's class A key, we indicate that all other
// devices have a stale key
exports.outdateDevices = db.wait(function(userId, deviceId, cb) {
var devicesKey = userId + '/devices';
async.waterfall([
// get device info
function(cb) { kv.get(devicesKey, cb); },
// update devices info and save
function(devices, cb) {
if (!devices) return cb('UnknownUser');
if (!devices.value[deviceId]) return cb('UnknownDevice');
for (var id in devices.value) {
// all other devices have a stale key
if (id !== deviceId) devices.value[id].latestVersion = false;
}
// device's last request was now
devices.value[deviceId].lastKeyRequest = +new Date();
kv.set(devicesKey, devices.value, cb);
}
], cb);
});
// When a device bumps the user's class A key, we indicate that all other
// devices have a stale key
exports.bumpkA = db.wait(function(userId, cb) {
var metaKey = userId + '/meta';
var user;
async.waterfall([
// get user info
function(cb) { kv.get(metaKey, cb); },
function(meta, cb) { user = meta; cb(null); },
// get new class A key
util.getKA,
// update devices info and save
function(kA, cb) {
if (!user) return cb('UnknownUser');
// set new class A key and increment version
user.value.kA = kA;
user.value.kA_version++;
kv.set(metaKey, user.value, function(err) { cb(err, user.value); });
}
], cb);
});
// get meta data associated with a user
exports.getUser = db.wait(function(userId, cb) {
kv.get(userId + '/meta', function(err, doc) {
if (err) return cb(err);
if (!doc) return cb('UnknownUser');
cb(null, doc.value);
});
});
// utility for returning a new device object
function initDevice() {
return {
hasLatestKey: true,
lastKeyRequest: +new Date()
};
}

18
lib/util.js Normal file
Просмотреть файл

@ -0,0 +1,18 @@
var crypto = require('crypto');
function getKA(cb) {
return crypto.randomBytes(32, function(err, buf) {
cb(null, buf.toString('hex'));
});
}
function getDeviceId(cb) {
return crypto.randomBytes(32, function(err, buf) {
cb(null, buf.toString('hex'));
});
}
module.exports = {
getKA: getKA,
getDeviceId: getDeviceId
};

50
lib/verify.js Normal file
Просмотреть файл

@ -0,0 +1,50 @@
var https = require('https');
var http = require('http');
var url = require('url');
var protocols = {
'http:': http,
'https:': https
};
// sends an assertion to a verification server
module.exports = function verify(assertion, audience, verifier_url, cb) {
var assertion = JSON.stringify({
assertion: assertion,
audience: audience
});
var verifier = url.parse(verifier_url);
var vreq = protocols[verifier.protocol].request({
host: verifier.hostname,
port: verifier.port,
path: verifier.path,
method: 'POST',
headers: {
'Content-Length': assertion.length,
'Content-Type': 'application/json'
}
}, function (vres) {
var result = "";
vres.on('data', function(chunk) { result += chunk; });
vres.on('end', function() {
try {
var data = JSON.parse(result);
if (data.status === 'okay') {
cb(null, data);
} else {
cb(data.reason);
}
} catch(e) {
cb(e);
}
});
});
vreq.on('error', function(e) {
console.error('problem with request: ' + e.message, e.stack);
});
vreq.write(assertion);
vreq.end();
}

29
package.json Normal file
Просмотреть файл

@ -0,0 +1,29 @@
{
"name": "picl-keyserver",
"version": "0.0.0",
"description": "keyserver bits for Profile-In-the-CLoud",
"main": "index.js",
"scripts": {
"test": "NODE_ENV=test ./node_modules/.bin/mocha -R spec --recursive --timeout 10000 --ignore-leaks",
"start": "NODE_ENV=local node index.js"
},
"repository": {
"type": "git",
"url": "git://github.com/mozilla/picl-keyserver.git"
},
"license": "MPL 2.0",
"dependencies": {
"hapi": "git://github.com/zaach/hapi.git#develop",
"awsbox": "0.4.1",
"convict": "git://github.com/zaach/node-convict.git#disorderly",
"picl-server": "git://github.com/mozilla/picl-server.git",
"async": "0.1.22",
"node-uuid": "1.4.0"
},
"optionalDependencies": {
"couchbase": "0.0.11"
},
"devDependencies": {
"mocha": "1.8.1"
}
}

186
routes/index.js Normal file
Просмотреть файл

@ -0,0 +1,186 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const Hapi = require('hapi');
const users = require('../lib/users.js');
const prereqs = require('../lib/prereqs.js');
const S = Hapi.Types.String;
const B = Hapi.Types.Boolean;
const N = Hapi.Types.Number;
var routes = [
{
method: 'GET',
path: '/__heartbeat__',
config: {
handler: heartbeat
}
},
{
method: 'PUT',
path: '/user/create',
handler: create,
config: {
description: 'create a new user',
pre: [ prereqs.email ],
validate: {
schema: {
assertion: S(),
email: S()
}
},
response: {
schema: {
success: B().required(),
kA: S().required(),
deviceId: S().required(),
version: N().integer().required()
}
}
}
},
{
method: 'PUT',
path: '/device/create',
handler: device,
config: {
description: 'create a new device for the user',
pre: [ prereqs.email, prereqs.userId, prereqs.user ],
validate: {
schema: {
assertion: S(),
email: S()
}
},
response: {
schema: {
success: B().required(),
kA: S().required(),
deviceId: S().required(),
version: N().integer().required()
}
}
}
},
{
method: 'PUT',
path: '/user/get/{deviceId}',
handler: get,
config: {
description: 'get user meta data',
pre: [ prereqs.email, prereqs.userId, prereqs.user ],
validate: {
schema: {
assertion: S(),
email: S()
}
},
response: {
schema: {
success: B().required(),
kA: S().required(),
version: N().integer().required()
}
}
}
},
{
method: 'PUT',
path: '/user/bump/{deviceId}',
handler: bump,
config: {
description: 'get user meta data',
pre: [ prereqs.email, prereqs.userId ],
validate: {
schema: {
assertion: S(),
email: S()
}
},
response: {
schema: {
success: B().required(),
kA: S().required(),
version: N().integer().required()
}
}
}
}
];
// heartbeat
function heartbeat(request) {
request.reply.payload('ok').type('text/plain').send();
}
// create a user by assertion
function create(request) {
users.create(request.pre.email, function(err, result) {
if (err) return request.reply(Hapi.Error.badRequest(err));
request.reply({
success: true,
kA: result.kA,
deviceId: result.deviceId,
version: result.version
});
});
}
// add a new device to a user's account
function device(request) {
var user = request.pre.user;
users.addDevice(request.pre.userId, function(err, deviceId) {
if (err) return request.reply(Hapi.Error.badRequest(err));
request.reply({
success: true,
kA: user.kA,
deviceId: deviceId,
version: user.kA_version
});
});
}
// get a user's class A key and its version number
function get(request) {
var pre = request.pre;
// update the device's last kA request time
users.updateDevice(pre.userId, request.params.deviceId, function(err) {
if (err) return request.reply(Hapi.Error.badRequest(err));
request.reply({
success: true,
kA: pre.user.kA,
version: pre.user.kA_version
});
});
}
// create a new class A key and bump the version number
function bump(request) {
var pre = request.pre;
// mark all other devices as having a stale key
users.outdateDevices(pre.userId, request.params.deviceId, function(err) {
if (err) return request.reply(Hapi.Error.badRequest(err));
// create a new key and bump the version
users.bumpkA(pre.userId, function(err, user) {
if (err) return request.reply(Hapi.Error.badRequest(err));
request.reply({
success: true,
kA: user.kA,
version: user.kA_version
});
});
});
}
module.exports = routes;

33
server.js Normal file
Просмотреть файл

@ -0,0 +1,33 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const Hapi = require('hapi');
var config = require('./lib/config.js');
var helpers = require('./lib/helpers.js');
// load array of routes
var routes = require('./routes');
// server settings
var settings = {
monitor: true
};
// Create a server with a host and port
var port = config.get('bind_to.port');
var host = config.get('bind_to.host');
var server = new Hapi.Server(host, port, settings);
server.addHelper('email', helpers.email);
server.addHelper('verify', helpers.verify);
server.addHelper('userId', helpers.userId);
server.addHelper('user', helpers.user);
server.addRoutes(routes);
module.exports = server;

47
test/helpers.js Normal file
Просмотреть файл

@ -0,0 +1,47 @@
var http = require('http');
exports.server = require('../server');
exports.makeRequest = function (method, path, options, callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
var next = function (res) {
return callback(res);
};
this.inject({
method: method,
url: path,
payload: JSON.stringify(options.payload),
headers: options.headers
}, next);
};
exports.getUser = function(audience, cb) {
console.log('getting aud', encodeURIComponent(audience));
var req = http.request({
host: 'personatestuser.org',
path: '/email_with_assertion/' + encodeURIComponent(audience) + '/prod',
method: 'GET'
}, function (res) {
var result = '';
res.on('data', function(chunk) { result += chunk; });
res.on('end', function() {
try {
var data = JSON.parse(result);
} catch (e) {
return cb(e);
}
cb(null, data);
});
});
req.on('error', function(e) {
console.error('get user error:', e);
cb(e);
});
req.end();
};

Просмотреть файл

@ -0,0 +1,14 @@
var assert = require('assert');
var helpers = require('../helpers');
var server = helpers.server;
var makeRequest = helpers.makeRequest.bind(server);
describe('heartbeat', function() {
it('returns ok', function(done) {
makeRequest('GET', '/__heartbeat__', function(res) {
assert.equal(res.statusCode, 200);
assert.equal(res.result, 'ok');
done();
});
});
});

89
test/integration/user.js Normal file
Просмотреть файл

@ -0,0 +1,89 @@
var assert = require('assert');
var config = require('../../lib/config');
var helpers = require('../helpers');
var server = helpers.server;
var makeRequest = helpers.makeRequest.bind(server);
var TEST_AUDIENCE = config.get('public_url');
var TEST_EMAIL;
var TEST_ASSERTION;
var TEST_TOKEN = 'foobar';
/*describe('get user', function() {*/
//it('can get user email and assertion', function(done) {
//helpers.getUser(TEST_AUDIENCE, function(err, user) {
//TEST_EMAIL = user.email;
//TEST_ASSERTION = user.assertion;
//assert.ok(TEST_EMAIL);
//assert.ok(TEST_ASSERTION);
//done();
//});
//});
/*});*/
describe('user', function() {
var kA, version, deviceId;
it('should create a new account', function(done) {
makeRequest('PUT', '/user/create', {
//payload: { assertion: TEST_ASSERTION }
payload: { email: TEST_EMAIL }
}, function(res) {
assert.equal(res.statusCode, 200);
assert.ok(res.result.kA);
assert.ok(res.result.deviceId);
assert.equal(res.result.version, 1);
kA = res.result.kA;
done();
});
});
it('should add a new device', function(done) {
makeRequest('PUT', '/device/create', {
//payload: { assertion: TEST_ASSERTION }
payload: { email: TEST_EMAIL }
}, function(res) {
assert.equal(res.statusCode, 200);
assert.equal(kA, res.result.kA);
assert.ok(res.result.deviceId);
assert.equal(res.result.version, 1);
deviceId = res.result.deviceId;
done();
});
});
it('should get user info', function(done) {
makeRequest('PUT', '/user/get/' + deviceId, {
//payload: { assertion: TEST_ASSERTION }
payload: { email: TEST_EMAIL }
}, function(res) {
assert.equal(res.statusCode, 200);
assert.equal(kA, res.result.kA);
assert.equal(res.result.version, 1);
done();
});
});
it('should bump version', function(done) {
makeRequest('PUT', '/user/bump/' + deviceId, {
//payload: { assertion: TEST_ASSERTION }
payload: { email: TEST_EMAIL }
}, function(res) {
assert.equal(res.statusCode, 200);
assert.notEqual(kA, res.result.kA);
assert.equal(res.result.version, 2);
done();
});
});
});

0
test/users.js Normal file
Просмотреть файл