فهرست منبع

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

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

GitOrigin-RevId: d23ec86d63739a61f1e45f04ed41ea7d991ddb0e
Jakob Ackermann 3 سال پیش
والد
کامیت
82d59a18d9

+ 145 - 68
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 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 (:)')
-    }
+class AbstractAccessTokenScheme {
+  constructor(cipherLabel, cipherPassword) {
+    this.cipherLabel = cipherLabel
+    this.cipherPassword = cipherPassword
+  }
 
-    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')
+  /**
+   * @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')
     }
   }
+}
 
-  encryptJson(json, callback) {
-    const string = JSON.stringify(json)
-    crypto.randomBytes(32, (err, bytes) => {
-      if (err) {
-        return callback(err)
+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.schemeByCipherLabel = new Map()
+    for (const cipherLabel of Object.keys(settings.cipherPasswords)) {
+      if (!cipherLabel) {
+        throw new Error('cipherLabel cannot be empty')
       }
-      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'
-          )}`
+      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}`
+        )
+      }
 
-  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'))
+      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)
     }
-    if (!iv) {
-      return callback(new Error('token scheme v1 is not supported anymore'))
+
+    this.defaultScheme = this.schemeByCipherLabel.get(settings.cipherLabel)
+    if (!this.defaultScheme) {
+      throw new Error(`unknown default cipherLabel ${settings.cipherLabel}`)
     }
+  }
 
-    keyFn32(password, Buffer.from(salt, 'hex'), 32, (err, key) => {
-      let json
-      if (err) {
-        logger.err({ err }, 'error getting Fn key')
-        return callback(err)
-      }
+  encryptJson(json, callback) {
+    this.defaultScheme.encryptJson(json).then(s => callback(null, s), callback)
+  }
 
-      const decipher = crypto.createDecipheriv(
-        ALGORITHM,
-        key,
-        Buffer.from(iv, 'hex')
+  decryptToJson(encryptedJson, callback) {
+    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)
   }
 }
 

+ 2 - 7
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"
   }
 }

+ 146 - 10
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)

+ 3 - 12
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": {