From 82d59a18d9df39db9806f8349fd2ee4ae7f14e6b Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Tue, 14 Feb 2023 14:17:55 +0000 Subject: [PATCH] Merge pull request #11536 from overleaf/jpa-access-token-encryptor-v3 [access-token-encryptor] rewrite module and add scheme v3 GitOrigin-RevId: d23ec86d63739a61f1e45f04ed41ea7d991ddb0e --- .../lib/js/AccessTokenEncryptor.js | 213 ++++++++++++------ libraries/access-token-encryptor/package.json | 9 +- .../test/unit/js/AccessTokenEncryptorTests.js | 156 ++++++++++++- package-lock.json | 15 +- 4 files changed, 296 insertions(+), 97 deletions(-) diff --git a/libraries/access-token-encryptor/lib/js/AccessTokenEncryptor.js b/libraries/access-token-encryptor/lib/js/AccessTokenEncryptor.js index 5e4140d426..a19cc49437 100644 --- a/libraries/access-token-encryptor/lib/js/AccessTokenEncryptor.js +++ b/libraries/access-token-encryptor/lib/js/AccessTokenEncryptor.js @@ -1,88 +1,165 @@ +const { promisify } = require('util') const crypto = require('crypto') -const logger = require('@overleaf/logger') const ALGORITHM = 'aes-256-ctr' -const keyFn32 = (password, salt, keyLength, callback) => - crypto.pbkdf2(password, salt, 10000, 32, 'sha1', callback) +const cryptoHkdf = promisify(crypto.hkdf) +const cryptoPbkdf2 = promisify(crypto.pbkdf2) +const cryptoRandomBytes = promisify(crypto.randomBytes) + +class AbstractAccessTokenScheme { + constructor(cipherLabel, cipherPassword) { + this.cipherLabel = cipherLabel + this.cipherPassword = cipherPassword + } + + /** + * @param {Object} json + * @return {Promise} + */ + async encryptJson(json) { + throw new Error('encryptJson is not implemented') + } + + /** + * @param {string} encryptedJson + * @return {Promise} + */ + async decryptToJson(encryptedJson) { + throw new Error('decryptToJson is not implemented') + } +} + +class AccessTokenSchemeWithGenericKeyFn extends AbstractAccessTokenScheme { + /** + * @param {Buffer} salt + * @return {Promise} + */ + async keyFn(salt) { + throw new Error('keyFn is not implemented') + } + + async encryptJson(json) { + const plainText = JSON.stringify(json) + + const bytes = await cryptoRandomBytes(32) + const salt = bytes.slice(0, 16) + const iv = bytes.slice(16, 32) + const key = await this.keyFn(salt) + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) + const cipherText = + cipher.update(plainText, 'utf8', 'base64') + cipher.final('base64') + + return [ + this.cipherLabel, + salt.toString('hex'), + cipherText, + iv.toString('hex'), + ].join(':') + } + + async decryptToJson(encryptedJson) { + const [, salt, cipherText, iv] = encryptedJson.split(':', 4) + const key = await this.keyFn(Buffer.from(salt, 'hex')) + + const decipher = crypto.createDecipheriv( + ALGORITHM, + key, + Buffer.from(iv, 'hex') + ) + const plainText = + decipher.update(cipherText, 'base64', 'utf8') + decipher.final('utf8') + try { + return JSON.parse(plainText) + } catch (e) { + throw new Error('error decrypting token') + } + } +} + +class AccessTokenSchemeV2 extends AccessTokenSchemeWithGenericKeyFn { + async keyFn(salt) { + return cryptoPbkdf2(this.cipherPassword, salt, 10000, 32, 'sha1') + } +} + +class AccessTokenSchemeV3 extends AccessTokenSchemeWithGenericKeyFn { + async keyFn(salt) { + const optionalInfo = '' + return cryptoHkdf('sha512', this.cipherPassword, salt, optionalInfo, 32) + } +} class AccessTokenEncryptor { constructor(settings) { - this.settings = settings - this.cipherLabel = this.settings.cipherLabel - if (this.cipherLabel && this.cipherLabel.match(/:/)) { - throw Error('cipherLabel must not contain a colon (:)') + this.schemeByCipherLabel = new Map() + for (const cipherLabel of Object.keys(settings.cipherPasswords)) { + if (!cipherLabel) { + throw new Error('cipherLabel cannot be empty') + } + if (cipherLabel.match(/:/)) { + throw new Error( + `cipherLabel must not contain a colon (:), got ${cipherLabel}` + ) + } + const [cipherLabelNoVersion, version] = cipherLabel.split('-') + if (!version) { + throw new Error( + `cipherLabel must contain version suffix (e.g. 2042.1-v42), got ${cipherLabel}` + ) + } + + const cipherPassword = settings.cipherPasswords[cipherLabel] + if (!cipherPassword) { + throw new Error(`cipherPasswords['${cipherLabel}'] is missing`) + } + if (cipherPassword.length < 16) { + throw new Error(`cipherPasswords['${cipherLabel}'] is too short`) + } + + let scheme, schemeNoVersion + switch (version) { + case 'v2': + scheme = new AccessTokenSchemeV2(cipherLabel, cipherPassword) + schemeNoVersion = new AccessTokenSchemeV2( + cipherLabelNoVersion, + cipherPassword + ) + break + case 'v3': + scheme = new AccessTokenSchemeV3(cipherLabel, cipherPassword) + schemeNoVersion = new AccessTokenSchemeV3( + cipherLabelNoVersion, + cipherPassword + ) + break + default: + throw new Error(`unknown version '${version}' for ${cipherLabel}`) + } + this.schemeByCipherLabel.set(cipherLabel, scheme) + this.schemeByCipherLabel.set(cipherLabelNoVersion, schemeNoVersion) } - this.cipherPassword = this.settings.cipherPasswords[this.cipherLabel] - if (!this.cipherPassword) { - throw Error('cipherPassword not set') - } - if (this.cipherPassword.length < 16) { - throw Error('cipherPassword too short') + this.defaultScheme = this.schemeByCipherLabel.get(settings.cipherLabel) + if (!this.defaultScheme) { + throw new Error(`unknown default cipherLabel ${settings.cipherLabel}`) } } encryptJson(json, callback) { - const string = JSON.stringify(json) - crypto.randomBytes(32, (err, bytes) => { - if (err) { - return callback(err) - } - const salt = bytes.slice(0, 16) - const iv = bytes.slice(16, 32) - - keyFn32(this.cipherPassword, salt, 32, (err, key) => { - if (err) { - logger.err({ err }, 'error getting Fn key') - return callback(err) - } - - const cipher = crypto.createCipheriv(ALGORITHM, key, iv) - const crypted = - cipher.update(string, 'utf8', 'base64') + cipher.final('base64') - - callback( - null, - `${this.cipherLabel}:${salt.toString('hex')}:${crypted}:${iv.toString( - 'hex' - )}` - ) - }) - }) + this.defaultScheme.encryptJson(json).then(s => callback(null, s), callback) } decryptToJson(encryptedJson, callback) { - const [label, salt, cipherText, iv] = encryptedJson.split(':', 4) - const password = this.settings.cipherPasswords[label] - if (!password || password.length < 16) { - return callback(new Error('invalid password')) - } - if (!iv) { - return callback(new Error('token scheme v1 is not supported anymore')) - } - - keyFn32(password, Buffer.from(salt, 'hex'), 32, (err, key) => { - let json - if (err) { - logger.err({ err }, 'error getting Fn key') - return callback(err) - } - - const decipher = crypto.createDecipheriv( - ALGORITHM, - key, - Buffer.from(iv, 'hex') + const [label] = encryptedJson.split(':', 1) + const scheme = this.schemeByCipherLabel.get(label) + if (!scheme) { + return callback( + new Error('unknown access-token-encryptor label ' + label) ) - const dec = - decipher.update(cipherText, 'base64', 'utf8') + decipher.final('utf8') - try { - json = JSON.parse(dec) - } catch (e) { - return callback(new Error('error decrypting token')) - } - callback(null, json) - }) + } + scheme.decryptToJson(encryptedJson).then(o => callback(null, o), callback) } } diff --git a/libraries/access-token-encryptor/package.json b/libraries/access-token-encryptor/package.json index 7ddd515bc1..ea340c8cd5 100644 --- a/libraries/access-token-encryptor/package.json +++ b/libraries/access-token-encryptor/package.json @@ -1,6 +1,6 @@ { "name": "@overleaf/access-token-encryptor", - "version": "2.2.0", + "version": "3.0.0", "description": "", "main": "index.js", "scripts": { @@ -17,16 +17,11 @@ "lodash": "^4.17.21" }, "peerDependencies": { - "@overleaf/logger": "*", "mongodb": "*" }, "devDependencies": { - "@overleaf/logger": "*", - "bunyan": "^1.8.15", "chai": "^4.3.6", "mocha": "^10.2.0", - "nock": "0.15.2", - "sandboxed-module": "^2.0.4", - "sinon": "^9.2.4" + "sandboxed-module": "^2.0.4" } } diff --git a/libraries/access-token-encryptor/test/unit/js/AccessTokenEncryptorTests.js b/libraries/access-token-encryptor/test/unit/js/AccessTokenEncryptorTests.js index 6ce394805e..8dd15d3b07 100644 --- a/libraries/access-token-encryptor/test/unit/js/AccessTokenEncryptorTests.js +++ b/libraries/access-token-encryptor/test/unit/js/AccessTokenEncryptorTests.js @@ -13,30 +13,118 @@ describe('AccessTokenEncryptor', function () { '2016.1:76a7d64a444ccee1a515b49c44844a69:m5YSkexUsLjcF4gLncm72+k=' this.encrypted2019 = '2019.1:627143b2ab185a020c8720253a4c984e:7gnY6Ez3/Y3UWgLHLfBtJsE=:bf75cecb6aeea55b3c060e1122d2a82d' + this.encrypted2019v2 = + '2019.1-v2:627143b2ab185a020c8720253a4c984e:7gnY6Ez3/Y3UWgLHLfBtJsE=:bf75cecb6aeea55b3c060e1122d2a82d' + this.encrypted2023 = + '2023.1-v3:a6dd3781dd6ce93a4134874b505a209c:9TdIDAc8V9SeR0ffSn63Jj4=:d8b2de0b733c81b949993dce229abb4c' this.badLabel = 'xxxxxx:c7a39310056b694c:jQf+Uh5Den3JREtvc82GW5Q=' this.badKey = '2015.1:d7a39310056b694c:jQf+Uh5Den3JREtvc82GW5Q=' this.badCipherText = '2015.1:c7a39310056b694c:xQf+Uh5Den3JREtvc82GW5Q=' this.settings = { cipherLabel: '2019.1', cipherPasswords: { - 2016.1: '11111111111111111111111111111111111111', - 2015.1: '22222222222222222222222222222222222222', - 2019.1: '33333333333333333333333333333333333333', + '2019.1-v2': '33333333333333333333333333333333333333', + '2023.1-v3': '44444444444444444444444444444444444444', }, } this.AccessTokenEncryptor = SandboxedModule.require(modulePath, { globals: { Buffer, }, - requires: { - '@overleaf/logger': { - err() {}, - }, - }, }) this.encryptor = new this.AccessTokenEncryptor(this.settings) }) + describe('invalid settings', function () { + it('should flag missing label', function () { + expect( + () => + new this.AccessTokenEncryptor({ + cipherLabel: '', + cipherPasswords: { '': '' }, + }) + ).to.throw(/cipherLabel cannot be empty/) + }) + + it('should flag invalid label with colon', function () { + expect( + () => + new this.AccessTokenEncryptor({ + cipherLabel: '2023:1-v2', + cipherPasswords: { '2023:1-v2': '' }, + }) + ).to.throw(/colon/) + }) + + it('should flag missing password', function () { + expect( + () => + new this.AccessTokenEncryptor({ + cipherPasswords: { '2023.1-v3': '' }, + cipherVersions: { '2023.1-v3': 'v3' }, + }) + ).to.throw(/cipherPasswords.+ missing/) + expect( + () => + new this.AccessTokenEncryptor({ + cipherLabel: '2023.1-v3', + cipherPasswords: { '2023.1-v3': undefined }, + }) + ).to.throw(/cipherPasswords.+ missing/) + }) + + it('should flag short password', function () { + expect( + () => + new this.AccessTokenEncryptor({ + cipherLabel: '2023.1-v3', + cipherPasswords: { '2023.1-v3': 'foo' }, + }) + ).to.throw(/cipherPasswords.+ too short/) + }) + + it('should flag missing version', function () { + expect( + () => + new this.AccessTokenEncryptor({ + cipherLabel: '2023.1', + cipherPasswords: { 2023.1: '11111111111111111111111111111111' }, + }) + ).to.throw(/must contain version suffix/) + expect( + () => + new this.AccessTokenEncryptor({ + cipherLabel: '2023.1-', + cipherPasswords: { '2023.1-': '11111111111111111111111111111111' }, + }) + ).to.throw(/must contain version suffix/) + }) + + it('should flag invalid version', function () { + expect( + () => + new this.AccessTokenEncryptor({ + cipherLabel: '2023.1-v0', + cipherPasswords: { + '2023.1-v0': '11111111111111111111111111111111', + }, + }) + ).to.throw(/unknown version/) + }) + + it('should flag unknown default scheme', function () { + expect( + () => + new this.AccessTokenEncryptor({ + cipherLabel: '2000.1-v3', + cipherPasswords: { + '2023.1-v3': '11111111111111111111111111111111', + }, + }) + ).to.throw(/unknown default cipherLabel/) + }) + }) + describe('encrypt', function () { it('should encrypt the object', function (done) { this.encryptor.encryptJson(this.testObject, (err, encrypted) => { @@ -58,6 +146,34 @@ describe('AccessTokenEncryptor', function () { }) }) }) + + describe('v3', function () { + beforeEach(function () { + this.settings.cipherLabel = '2023.1-v3' + this.encryptor = new this.AccessTokenEncryptor(this.settings) + }) + + it('should encrypt the object', function (done) { + this.encryptor.encryptJson(this.testObject, (err, encrypted) => { + expect(err).to.be.null + encrypted.should.match( + /^2023.1-v3:[0-9a-f]{32}:[a-zA-Z0-9=+/]+:[0-9a-f]{32}$/ + ) + done() + }) + }) + + it('should encrypt the object differently the next time', function (done) { + this.encryptor.encryptJson(this.testObject, (err, encrypted1) => { + expect(err).to.be.null + this.encryptor.encryptJson(this.testObject, (err, encrypted2) => { + expect(err).to.be.null + encrypted1.should.not.equal(encrypted2) + done() + }) + }) + }) + }) }) describe('decrypt', function () { @@ -75,7 +191,9 @@ describe('AccessTokenEncryptor', function () { it('should not be able to decrypt 2015 string', function (done) { this.encryptor.decryptToJson(this.encrypted2015, (err, decrypted) => { expect(err).to.exist - expect(err.message).to.equal('token scheme v1 is not supported anymore') + expect(err.message).to.equal( + 'unknown access-token-encryptor label 2015.1' + ) expect(decrypted).to.not.exist done() }) @@ -84,7 +202,9 @@ describe('AccessTokenEncryptor', function () { it('should not be able to decrypt a 2016 string', function (done) { this.encryptor.decryptToJson(this.encrypted2016, (err, decrypted) => { expect(err).to.exist - expect(err.message).to.equal('token scheme v1 is not supported anymore') + expect(err.message).to.equal( + 'unknown access-token-encryptor label 2016.1' + ) expect(decrypted).to.not.exist done() }) @@ -98,6 +218,22 @@ describe('AccessTokenEncryptor', function () { }) }) + it('should decrypt an 2019 string with version to get the same object', function (done) { + this.encryptor.decryptToJson(this.encrypted2019v2, (err, decrypted) => { + expect(err).to.be.null + expect(decrypted).to.deep.equal(this.testObject) + done() + }) + }) + + it('should decrypt an 2023 string to get the same object', function (done) { + this.encryptor.decryptToJson(this.encrypted2023, (err, decrypted) => { + expect(err).to.be.null + expect(decrypted).to.deep.equal(this.testObject) + done() + }) + }) + it('should return an error when decrypting an invalid label', function (done) { this.encryptor.decryptToJson(this.badLabel, (err, decrypted) => { expect(err).to.be.instanceof(Error) diff --git a/package-lock.json b/package-lock.json index d2c3c79869..63f2a939ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,22 +78,17 @@ }, "libraries/access-token-encryptor": { "name": "@overleaf/access-token-encryptor", - "version": "2.2.0", + "version": "3.0.0", "license": "AGPL-3.0-only", "dependencies": { "lodash": "^4.17.21" }, "devDependencies": { - "@overleaf/logger": "*", - "bunyan": "^1.8.15", "chai": "^4.3.6", "mocha": "^10.2.0", - "nock": "0.15.2", - "sandboxed-module": "^2.0.4", - "sinon": "^9.2.4" + "sandboxed-module": "^2.0.4" }, "peerDependencies": { - "@overleaf/logger": "*", "mongodb": "*" } }, @@ -42326,14 +42321,10 @@ "@overleaf/access-token-encryptor": { "version": "file:libraries/access-token-encryptor", "requires": { - "@overleaf/logger": "*", - "bunyan": "^1.8.15", "chai": "^4.3.6", "lodash": "^4.17.21", "mocha": "^10.2.0", - "nock": "0.15.2", - "sandboxed-module": "^2.0.4", - "sinon": "^9.2.4" + "sandboxed-module": "^2.0.4" } }, "@overleaf/analytics": {