mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
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:
parent
c524fee690
commit
82d59a18d9
4 changed files with 296 additions and 97 deletions
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
15
package-lock.json
generated
|
@ -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": {
|
||||
|
|
Loading…
Reference in a new issue