Skip to content

Commit

Permalink
Implement key filter mechanisms following Abuse-Resistant OpenPGP Key…
Browse files Browse the repository at this point in the history
…stores draft
  • Loading branch information
toberndo committed Dec 18, 2023
1 parent 54b8f79 commit e419922
Show file tree
Hide file tree
Showing 14 changed files with 1,148 additions and 58 deletions.
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@
"prefer-arrow-callback": ["warn", {"allowNamedFunctions": true}], // require arrow functions as callbacks
"prefer-const": ["warn", {"destructuring": "all"}], // require const declarations for variables that are never reassigned after declared
"prefer-template": 1, // require template literals instead of string concatenation
"template-curly-spacing": ["warn", "never"] // require or disallow spacing around embedded expressions of template strings
"template-curly-spacing": ["warn", "never"], // require or disallow spacing around embedded expressions of template strings
"no-template-curly-in-string": "warn"
},

"root": true
Expand Down
49 changes: 44 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,35 @@ DELETE /api/v1/key?keyId=b8e4105cc9dedc77 OR [email protected]
GET /api/v1/key?op=verifyRemove&keyId=b8e4105cc9dedc77&nonce=6a314915c09368224b11df0feedbc53c
```

## Abuse resistant key server

The key server implements mechanisms described in the draft [Abuse-Resistant OpenPGP Keystores](https://datatracker.ietf.org/doc/html/draft-dkg-openpgp-abuse-resistant-keystore-06) to mitigate various attacks related to flooding the key server with bogus keys or certificates. The filtering of keys can be customized with [environment variables](#settings).

In detail the following key components are filtered out:

* user attribute packets
* third-party certificates
* certificates exceeding 8383 bytes
* certificates that cannot be verified with primary key
* unhashed subpackets except: issuer, issuerFingerprint, embeddedSignature
* unhashed subpackets of embedded signatures
* user IDs without email address
* user IDs exceeding 1024 bytes
* user IDs that have no self certificate or revocation signature
* subkeys exceeding 8383 bytes
* above 5 revocation signatures. Hardest, earliest revocations are kept.
* superseded certificates. Newest 5 are kept.

A key is rejected if one of the following is detected:

* primary key packet exceeding 8383 bytes
* primary key packet is not version 4
* key without user ID
* key with more than 20 email addresses
* key with more than 20 subkeys
* key size exceeding 32768 bytes
* new uploaded key is not valid 24h in the future

# Language & DB

The server is written is in JavaScript ES2020 and runs on [Node.js](https://nodejs.org/) v18+.
Expand All @@ -166,7 +195,7 @@ It uses [MongoDB](https://www.mongodb.com/) v6.0+ as its database.

### Node.js (macOS)

This is how to install node on Mac OS using [homebrew](http://brew.sh/). For other operating systems, please refer to the [Node.js download page](https://nodejs.org/en/download/).
This is how to install node on Mac OS using [homebrew](https://brew.sh/). For other operating systems, please refer to the [Node.js download page](https://nodejs.org/en/download/).

```shell
brew update
Expand All @@ -175,7 +204,7 @@ brew install node

### MongoDB (macOS)

This is the installation guide to get a local development installation on macOS using [homebrew](http://brew.sh/). For other operating systems, please refer to the [MongoDB Installation Tutorials](https://www.mongodb.com/docs/v6.0/installation/#mongodb-installation-tutorials).
This is the installation guide to get a local development installation on macOS using [homebrew](https://brew.sh/). For other operating systems, please refer to the [MongoDB Installation Tutorials](https://www.mongodb.com/docs/v6.0/installation/#mongodb-installation-tutorials).

```shell
brew update
Expand Down Expand Up @@ -273,15 +302,25 @@ Available settings with its environment-variable-names, possible/example values
* SMTP_PORT=465
* SMTP_TLS=true
* SMTP_STARTTLS=true
* SMTP_PGP=true
* SMTP_PGP=**true**
(encrypt verification message with public key (allows to verify presence + usability of private key at owner of the email address))
* SMTP_USER=smtp_user
* SMTP_PASS=smtp_pass
* SENDER_NAME="OpenPGP Key Server"
* SENDER_EMAIL=noreply@example.com
* PUBLIC_KEY_PURGE_TIME=**30**
* SENDER_EMAIL=noreply@your-key-server.net
* PUBLIC_KEY_PURGE_TIME=**14**
(number of days after which uploaded keys are deleted if they have not been verified)

The following variables are available to customize the filtering behavior as outlined in [Abuse resistant key server](#abuse-resistant-key-server):

* PURIFY_KEY=**true** (main switch to enable filtering of keys)
* MAX_NUM_USER_EMAIL=**20** (max. number of email addresses per key)
* MAX_NUM_SUBKEY=**20** (max. number of subkeys per key)
* MAX_NUM_CERT=**5** (max. number of superseding certificates)
* MAX_SIZE_USERID=**1024**
* MAX_SIZE_PACKET=**8383**
* MAX_SIZE_KEY=**32768**

### Notes on SMTP

The key server uses [nodemailer](https://nodemailer.com) to send out emails upon public key upload to verify email address ownership. To test this feature locally, configure `SMTP_USER` and `SMTP_PASS` settings to your email test account. Make sure that `SMTP_USER` and `SENDER_EMAIL` match.
Expand Down
14 changes: 12 additions & 2 deletions config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ module.exports = {
port: process.env.SMTP_PORT,
tls: process.env.SMTP_TLS,
starttls: process.env.SMTP_STARTTLS,
pgp: process.env.SMTP_PGP,
pgp: process.env.SMTP_PGP ?? true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
Expand All @@ -44,7 +44,17 @@ module.exports = {
},

publicKey: {
purgeTimeInDays: process.env.PUBLIC_KEY_PURGE_TIME || 30
purgeTimeInDays: process.env.PUBLIC_KEY_PURGE_TIME || 14
},

purify: {
purifyKey: process.env.PURIFY_KEY ?? true,
maxNumUserEmail: process.env.MAX_NUM_USER_EMAIL || 20,
maxNumSubkey: process.env.MAX_NUM_SUBKEY || 20,
maxNumCert: process.env.MAX_NUM_CERT || 5,
maxSizeUserID: process.env.MAX_SIZE_USERID || 1024,
maxSizePacket: process.env.MAX_SIZE_PACKET || 8383,
maxSizeKey: process.env.MAX_SIZE_KEY || 32768
}

};
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"test": "npm run test:lint && npm run test:unit && npm run test:integration",
"test:lint": "eslint --ignore-pattern \"**/*.min.js\" config src test",
"test:unit": "mocha --require ./test/setup.js --recursive ./test/unit",
"test:purify": "mocha --require ./test/setup.js --recursive ./test/unit/purify-key-test.js",
"test:public": "mocha --require ./test/setup.js --recursive ./test/integration/public-key-test.js",
"test:integration": "mocha --exit --require ./test/setup.js --recursive ./test/integration",
"release": "npm run release:install && npm run release:archive",
"release:install": "rm -rf node_modules/ && npm ci --production",
Expand Down
26 changes: 26 additions & 0 deletions src/lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,36 @@ exports.url = function(origin, resource) {
return `${origin.protocol}://${origin.host}${resource || ''}`;
};

/**
* Validity status of a key
* @type {Object}
*/
exports.KEY_STATUS = {
invalid: 0,
expired: 1,
revoked: 2,
valid: 3,
no_self_cert: 4
};

/**
* Asynchronous wrapper for Array.prototype.filter()
* @param {Array} array
* @param {Function} asyncFilterFn
* @return {Promise<Array>}
*/
exports.filterAsync = async function(array, asyncFilterFn) {
const promises = array.map(async item => await asyncFilterFn(item) && item);
const result = await Promise.all(promises);
return result.filter(item => item);
};

/**
* Return Date one day in the future
* @return {Date}
*/
exports.getTomorrow = function() {
const now = new Date();
now.setDate(now.getDate() + 1);
return now;
};
8 changes: 2 additions & 6 deletions src/modules/email.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,12 @@ class Email {
log.error('Failed to parse PGP key in _pgpEncrypt\n%s\n%s', e, publicKeyArmored);
throw Boom.badImplementation('Failed to parse PGP key');
}
const now = new Date();
const keyCreationDate = key.getCreationTime();
// set message creation date if key has been created with future creation date
const msgCreationDate = keyCreationDate > now ? keyCreationDate : now;
try {
const message = await openpgp.createMessage({text: plaintext, date: msgCreationDate});
const message = await openpgp.createMessage({text: plaintext});
const ciphertext = await openpgp.encrypt({
message,
encryptionKeys: key,
date: msgCreationDate
date: util.getTomorrow()
});
return ciphertext;
} catch (error) {
Expand Down
83 changes: 47 additions & 36 deletions src/modules/pgp.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const {KEY_STATUS} = util;
* A simple wrapper around OpenPGP.js
*/
class PGP {
constructor() {
constructor(purify) {
this.purify = purify;
openpgp.config.showVersion = false;
openpgp.config.showComment = false;
}
Expand All @@ -32,17 +33,20 @@ class PGP {
key = await openpgp.readKey({armoredKey: publicKeyArmored});
} catch (e) {
log.error('Error reading PGP key\n%s\n%s', e, publicKeyArmored);
throw Boom.badRequest(`Error reading PGP key. ${e.message}`);
throw Boom.badRequest(`Error reading PGP key: ${e.message}`);
}
if (key.isPrivate()) {
log.error('Attempted private key upload');
throw Boom.badRequest('Error uploading private key. Please keep your private key secret and never upload it to key servers. Only public keys accepted.');
}
await this.purify.purifyKey(key);
// verify key
const verifyDate = new Date();
// accept keys valid 24h in the future
verifyDate.setUTCDate(verifyDate.getUTCDate() + 1);
await this.verifyKey(key, verifyDate);
const verifyDate = util.getTomorrow();
const keyStatus = await this.verifyKey(key, verifyDate);
if (keyStatus === KEY_STATUS.invalid) {
log.error('Invalid PGP key: primary key verification failed\n%s', key.armor());
throw Boom.badRequest('Invalid PGP key. Verification of the primary key failed.');
}
// check for at least one valid user ID
const userIds = await this.parseUserIds(key, verifyDate);
if (!userIds.length) {
Expand All @@ -51,6 +55,7 @@ class PGP {
}
// get algorithm details from primary key
const keyInfo = key.getAlgorithmInfo();
this.purify.checkMaxKeySize(key);
// public key document that is stored in the database
return {
keyId: key.getKeyID().toHex(),
Expand All @@ -65,34 +70,25 @@ class PGP {
}

/**
* Verify key. At least one valid user ID and signing or encryption key is required.
* Verify key
* @param {PublicKey} key
* @param {Date} date The verification date
* @return {Promise<undefined>}
* @throws {Error} If key verification failed
* @param {Date} verifyDate The verification date
* @return {Promise<Number>} The KEY_STATUS
*/
async verifyKey(key, verifyDate = new Date()) {
try {
await key.verifyPrimaryKey(verifyDate);
return KEY_STATUS.valid;
} catch (e) {
log.error('Invalid PGP key: primary key verification failed\n%s\n%s', e, key.armor());
throw Boom.badRequest(`Invalid PGP key. Key verification failed: ${e.message}`);
}
let signingKeyError;
let encryptionKeyError;
try {
await key.getSigningKey(null, verifyDate);
} catch (e) {
signingKeyError = e;
}
try {
await key.getEncryptionKey(null, verifyDate);
} catch (e) {
encryptionKeyError = e;
}
if (signingKeyError && encryptionKeyError) {
log.error('Invalid PGP key: no valid encryption or signing key found\n%s\n%s\n%s', encryptionKeyError, signingKeyError, key.armor());
throw Boom.badRequest(`Invalid PGP key. No valid encryption or signing key found: ${signingKeyError.message}`);
switch (e.message) {
case 'Primary key is revoked':
case 'Primary user is revoked':
return KEY_STATUS.revoked;
case 'Primary key is expired':
return KEY_STATUS.expired;
default:
return KEY_STATUS.invalid;
}
}
}

Expand Down Expand Up @@ -127,6 +123,10 @@ class PGP {
switch (e.message) {
case 'Self-certification is revoked':
return KEY_STATUS.revoked;
case 'No self-certifications found':
return user.revocationSignatures.length ? KEY_STATUS.revoked : KEY_STATUS.no_self_cert;
case 'Self-certification is invalid: Signature is expired':
return KEY_STATUS.expired;
default:
return KEY_STATUS.invalid;
}
Expand All @@ -135,18 +135,27 @@ class PGP {

/**
* Remove user IDs from armored key block which are not in array of user IDs
* @param {Array} userIds user IDs to be kept
* @param {String} armoredKey armored key block to be filtered
* @return {Promise<String>} filtered amored key block
* @param {Array} userIds user IDs to be kept
* @param {String} armoredKey armored key block to be filtered
* @param {Boolean} verifyEncryptionKey verify that key has encryption capabilities
* @return {Promise<String>} filtered amored key block
*/
async filterKeyByUserIds(userIds, armoredKey) {
async filterKeyByUserIds(userIds, armoredKey, verifyEncryptionKey) {
const emails = userIds.map(({email}) => email);
let key;
try {
key = await openpgp.readKey({armoredKey});
} catch (e) {
log.error('Failed to parse PGP key in filterKeyByUserIds\n%s\n%s', e, armoredKey);
throw Boom.badImplementation('Failed to parse PGP key');
throw Boom.badImplementation(`Failed to read PGP key: ${e.message}`);
}
try {
if (verifyEncryptionKey) {
await key.getEncryptionKey(null, util.getTomorrow());
}
} catch (e) {
log.error('Invalid PGP key: no valid encryption key found\n%s\n%s', e, armoredKey);
throw Boom.badRequest(`Invalid PGP key. No valid encryption key found: ${e.message}`);
}
key.users = key.users.filter(({userID}) => userID && emails.includes(util.normalizeEmail(userID.email)));
return key.armor();
Expand All @@ -164,16 +173,18 @@ class PGP {
srcKey = await openpgp.readKey({armoredKey: srcArmored});
} catch (e) {
log.error('Failed to parse source PGP key for update\n%s\n%s', e, srcArmored);
throw Boom.badImplementation('Failed to parse PGP key');
throw Boom.badImplementation(`Failed to read PGP key: ${e.message}`);
}
let dstKey;
try {
dstKey = await openpgp.readKey({armoredKey: dstArmored});
} catch (e) {
log.error('Failed to parse destination PGP key for update\n%s\n%s', e, dstArmored);
throw Boom.badImplementation('Failed to parse PGP key');
throw Boom.badImplementation(`Failed to read PGP key: ${e.message}`);
}
const updatedKey = await dstKey.update(srcKey);
this.purify.limitNumOfCertificates(updatedKey);
this.purify.checkMaxKeySize(updatedKey);
return updatedKey.armor();
}

Expand All @@ -189,7 +200,7 @@ class PGP {
key = await openpgp.readKey({armoredKey});
} catch (e) {
log.error('Failed to parse PGP key in removeUserId\n%s\n%s', e, armoredKey);
throw Boom.badImplementation('Failed to parse PGP key');
throw Boom.badImplementation(`Failed to read PGP key: ${e.message}`);
}
key.users = key.users.filter(({userID}) => userID && util.normalizeEmail(userID.email) !== email);
return key.armor();
Expand Down
4 changes: 2 additions & 2 deletions src/modules/public-key.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ class PublicKey {
*/
async _addKeyArmored(userIds, publicKeyArmored) {
for (const userId of userIds) {
userId.publicKeyArmored = await this._pgp.filterKeyByUserIds([userId], publicKeyArmored);
userId.publicKeyArmored = await this._pgp.filterKeyByUserIds([userId], publicKeyArmored, util.isTrue(config.email.pgp));
userId.notify = true;
}
}
Expand All @@ -150,7 +150,7 @@ class PublicKey {
*/
async _sendVerifyEmail({userIds, keyId}, origin, i18n) {
for (const userId of userIds) {
if (userId.notify && userId.notify === true) {
if (userId.notify === true) {
// generate nonce for verification
userId.nonce = util.random();
await this._email.send({template: tpl.verifyKey, userId, keyId, origin, publicKeyArmored: userId.publicKeyArmored, i18n});
Expand Down
Loading

0 comments on commit e419922

Please sign in to comment.