Merge pull request #11536 from overleaf/jpa-access-token-encryptor-v3

[access-token-encryptor] rewrite module and add scheme v3

GitOrigin-RevId: d23ec86d63739a61f1e45f04ed41ea7d991ddb0e
This commit is contained in:
Jakob Ackermann 2023-02-14 14:17:55 +00:00 committed by Copybot
parent c524fee690
commit 82d59a18d9
4 changed files with 296 additions and 97 deletions

View file

@ -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<string>}
*/
async encryptJson(json) {
throw new Error('encryptJson is not implemented')
}
/**
* @param {string} encryptedJson
* @return {Promise<Object>}
*/
async decryptToJson(encryptedJson) {
throw new Error('decryptToJson is not implemented')
}
}
class AccessTokenSchemeWithGenericKeyFn extends AbstractAccessTokenScheme {
/**
* @param {Buffer} salt
* @return {Promise<Buffer>}
*/
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)
}
}

View file

@ -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"
}
}

View file

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

15
package-lock.json generated
View file

@ -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": {