First working prototype of the keyserver
This commit is contained in:
parent
439ab77422
commit
2d07c34060
10
config/default.js
Normal file
10
config/default.js
Normal file
@ -0,0 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
server: {
|
||||
port: process.env.PORT || 8888,
|
||||
},
|
||||
log: {
|
||||
level: "silly"
|
||||
}
|
||||
};
|
7
config/production.js
Normal file
7
config/production.js
Normal file
@ -0,0 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
log: {
|
||||
level: "error"
|
||||
}
|
||||
};
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
//
|
||||
|
@ -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} 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
114
src/ctrl/user-id.js
Normal 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
109
src/ctrl/util.js
Normal 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;
|
||||
};
|
@ -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;
|
@ -29,17 +29,14 @@ class Mongo {
|
||||
* @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}
|
||||
*/
|
||||
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 {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 {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 {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.
|
||||
* Delete all documents matching a query.
|
||||
* @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 {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({});
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
this.setGetHeaders(ctx, params);
|
||||
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
|
||||
};
|
||||
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,17 +90,23 @@ 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} 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-Disposition', 'attachment; filename=openpgpkey.asc');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
@ -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,10 +55,37 @@ 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!');
|
||||
|
@ -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
|
||||
//
|
||||
|
||||
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;
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
69
test/integration/worker-test.js
Normal file
69
test/integration/worker-test.js
Normal 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
32
test/key1.asc
Normal 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-----
|
Loading…
Reference in New Issue
Block a user