mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #21589 from overleaf/jpa-s3-ssec-hkdf
[object-persistor] s3SSEC: add HKDF layer to KEK GitOrigin-RevId: 1def9e378e1dfd90f449ad392b0db2101584e17f
This commit is contained in:
parent
bec73ddfae
commit
a67560c26b
4 changed files with 109 additions and 28 deletions
|
@ -15,6 +15,9 @@ const {
|
||||||
const logger = require('@overleaf/logger')
|
const logger = require('@overleaf/logger')
|
||||||
|
|
||||||
const generateKey = promisify(Crypto.generateKey)
|
const generateKey = promisify(Crypto.generateKey)
|
||||||
|
const hkdf = promisify(Crypto.hkdf)
|
||||||
|
|
||||||
|
const AES256_KEY_LENGTH = 32
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import('aws-sdk').AWSError} AWSError
|
* @typedef {import('aws-sdk').AWSError} AWSError
|
||||||
|
@ -25,8 +28,8 @@ const generateKey = promisify(Crypto.generateKey)
|
||||||
* @property {boolean} automaticallyRotateDEKEncryption
|
* @property {boolean} automaticallyRotateDEKEncryption
|
||||||
* @property {boolean} ignoreErrorsFromDEKReEncryption
|
* @property {boolean} ignoreErrorsFromDEKReEncryption
|
||||||
* @property {(bucketName: string, path: string) => {bucketName: string, path: string}} pathToDataEncryptionKeyPath
|
* @property {(bucketName: string, path: string) => {bucketName: string, path: string}} pathToDataEncryptionKeyPath
|
||||||
* @property {(bucketName: string, path: string) => boolean} pathIsProjectFolder
|
* @property {(bucketName: string, path: string) => string} pathToProjectFolder
|
||||||
* @property {() => Promise<Array<Buffer>>} getKeyEncryptionKeys
|
* @property {() => Promise<Array<RootKeyEncryptionKey>>} getRootKeyEncryptionKeys
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -52,10 +55,47 @@ function isForbiddenError(err) {
|
||||||
return cause.statusCode === 403
|
return cause.statusCode === 403
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class RootKeyEncryptionKey {
|
||||||
|
/** @type {Buffer} */
|
||||||
|
#keyEncryptionKey
|
||||||
|
/** @type {Buffer} */
|
||||||
|
#salt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Buffer} keyEncryptionKey
|
||||||
|
* @param {Buffer} salt
|
||||||
|
*/
|
||||||
|
constructor(keyEncryptionKey, salt) {
|
||||||
|
if (keyEncryptionKey.byteLength !== AES256_KEY_LENGTH) {
|
||||||
|
throw new Error(`kek is not ${AES256_KEY_LENGTH} bytes long`)
|
||||||
|
}
|
||||||
|
this.#keyEncryptionKey = keyEncryptionKey
|
||||||
|
this.#salt = salt
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} prefix
|
||||||
|
* @return {Promise<SSECOptions>}
|
||||||
|
*/
|
||||||
|
async forProject(prefix) {
|
||||||
|
return new SSECOptions(
|
||||||
|
Buffer.from(
|
||||||
|
await hkdf(
|
||||||
|
'sha256',
|
||||||
|
this.#keyEncryptionKey,
|
||||||
|
this.#salt,
|
||||||
|
prefix,
|
||||||
|
AES256_KEY_LENGTH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class PerProjectEncryptedS3Persistor extends S3Persistor {
|
class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||||
/** @type {Settings} */
|
/** @type {Settings} */
|
||||||
#settings
|
#settings
|
||||||
/** @type {Promise<Array<SSECOptions>>} */
|
/** @type {Promise<Array<RootKeyEncryptionKey>>} */
|
||||||
#availableKeyEncryptionKeysPromise
|
#availableKeyEncryptionKeysPromise
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -65,10 +105,10 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||||
super(settings)
|
super(settings)
|
||||||
this.#settings = settings
|
this.#settings = settings
|
||||||
this.#availableKeyEncryptionKeysPromise = this.#settings
|
this.#availableKeyEncryptionKeysPromise = this.#settings
|
||||||
.getKeyEncryptionKeys()
|
.getRootKeyEncryptionKeys()
|
||||||
.then(keysAsBuffer => {
|
.then(rootKEKs => {
|
||||||
if (keysAsBuffer.length === 0) throw new Error('no kek provided')
|
if (rootKEKs.length === 0) throw new Error('no root kek provided')
|
||||||
return keysAsBuffer.map(buffer => new SSECOptions(buffer))
|
return rootKEKs
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,9 +116,16 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||||
await this.#availableKeyEncryptionKeysPromise
|
await this.#availableKeyEncryptionKeysPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
async #getCurrentKeyEncryptionKey() {
|
/**
|
||||||
const available = await this.#availableKeyEncryptionKeysPromise
|
* @param {string} bucketName
|
||||||
return available[0]
|
* @param {string} path
|
||||||
|
* @return {Promise<SSECOptions>}
|
||||||
|
*/
|
||||||
|
async #getCurrentKeyEncryptionKey(bucketName, path) {
|
||||||
|
const [currentRootKEK] = await this.#availableKeyEncryptionKeysPromise
|
||||||
|
return await currentRootKEK.forProject(
|
||||||
|
this.#settings.pathToProjectFolder(bucketName, path)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -87,7 +134,10 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||||
*/
|
*/
|
||||||
async getDataEncryptionKeySize(bucketName, path) {
|
async getDataEncryptionKeySize(bucketName, path) {
|
||||||
const dekPath = this.#settings.pathToDataEncryptionKeyPath(bucketName, path)
|
const dekPath = this.#settings.pathToDataEncryptionKeyPath(bucketName, path)
|
||||||
for (const ssecOptions of await this.#availableKeyEncryptionKeysPromise) {
|
for (const rootKEK of await this.#availableKeyEncryptionKeysPromise) {
|
||||||
|
const ssecOptions = await rootKEK.forProject(
|
||||||
|
this.#settings.pathToProjectFolder(bucketName, path)
|
||||||
|
)
|
||||||
try {
|
try {
|
||||||
return await super.getObjectSize(dekPath.bucketName, dekPath.path, {
|
return await super.getObjectSize(dekPath.bucketName, dekPath.path, {
|
||||||
ssecOptions,
|
ssecOptions,
|
||||||
|
@ -138,7 +188,7 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||||
{
|
{
|
||||||
// Do not overwrite any objects if already created
|
// Do not overwrite any objects if already created
|
||||||
ifNoneMatch: '*',
|
ifNoneMatch: '*',
|
||||||
ssecOptions: await this.#getCurrentKeyEncryptionKey(),
|
ssecOptions: await this.#getCurrentKeyEncryptionKey(bucketName, path),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return new SSECOptions(dataEncryptionKey)
|
return new SSECOptions(dataEncryptionKey)
|
||||||
|
@ -153,7 +203,10 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||||
const dekPath = this.#settings.pathToDataEncryptionKeyPath(bucketName, path)
|
const dekPath = this.#settings.pathToDataEncryptionKeyPath(bucketName, path)
|
||||||
let res
|
let res
|
||||||
let kekIndex = 0
|
let kekIndex = 0
|
||||||
for (const ssecOptions of await this.#availableKeyEncryptionKeysPromise) {
|
for (const rootKEK of await this.#availableKeyEncryptionKeysPromise) {
|
||||||
|
const ssecOptions = await rootKEK.forProject(
|
||||||
|
this.#settings.pathToProjectFolder(bucketName, path)
|
||||||
|
)
|
||||||
try {
|
try {
|
||||||
res = await super.getObjectStream(dekPath.bucketName, dekPath.path, {
|
res = await super.getObjectStream(dekPath.bucketName, dekPath.path, {
|
||||||
ssecOptions,
|
ssecOptions,
|
||||||
|
@ -171,12 +224,16 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||||
await Stream.promises.pipeline(res, buf)
|
await Stream.promises.pipeline(res, buf)
|
||||||
|
|
||||||
if (kekIndex !== 0 && this.#settings.automaticallyRotateDEKEncryption) {
|
if (kekIndex !== 0 && this.#settings.automaticallyRotateDEKEncryption) {
|
||||||
|
const ssecOptions = await this.#getCurrentKeyEncryptionKey(
|
||||||
|
bucketName,
|
||||||
|
path
|
||||||
|
)
|
||||||
try {
|
try {
|
||||||
await super.sendStream(
|
await super.sendStream(
|
||||||
dekPath.bucketName,
|
dekPath.bucketName,
|
||||||
dekPath.path,
|
dekPath.path,
|
||||||
Stream.Readable.from([buf.getContents()]),
|
Stream.Readable.from([buf.getContents()]),
|
||||||
{ ssecOptions: await this.#getCurrentKeyEncryptionKey() }
|
{ ssecOptions }
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.#settings.ignoreErrorsFromDEKReEncryption) {
|
if (this.#settings.ignoreErrorsFromDEKReEncryption) {
|
||||||
|
@ -252,7 +309,7 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||||
async deleteDirectory(bucketName, path, continuationToken) {
|
async deleteDirectory(bucketName, path, continuationToken) {
|
||||||
// Note: Listing/Deleting a prefix does not require SSE-C credentials.
|
// Note: Listing/Deleting a prefix does not require SSE-C credentials.
|
||||||
await super.deleteDirectory(bucketName, path, continuationToken)
|
await super.deleteDirectory(bucketName, path, continuationToken)
|
||||||
if (this.#settings.pathIsProjectFolder(bucketName, path)) {
|
if (this.#settings.pathToProjectFolder(bucketName, path) === path) {
|
||||||
const dekPath = this.#settings.pathToDataEncryptionKeyPath(
|
const dekPath = this.#settings.pathToDataEncryptionKeyPath(
|
||||||
bucketName,
|
bucketName,
|
||||||
path
|
path
|
||||||
|
@ -357,4 +414,8 @@ class CachedPerProjectEncryptedS3Persistor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = PerProjectEncryptedS3Persistor
|
module.exports = {
|
||||||
|
PerProjectEncryptedS3Persistor,
|
||||||
|
CachedPerProjectEncryptedS3Persistor,
|
||||||
|
RootKeyEncryptionKey,
|
||||||
|
}
|
||||||
|
|
|
@ -4,7 +4,9 @@ const GcsPersistor = require('./GcsPersistor')
|
||||||
const { S3Persistor } = require('./S3Persistor')
|
const { S3Persistor } = require('./S3Persistor')
|
||||||
const FSPersistor = require('./FSPersistor')
|
const FSPersistor = require('./FSPersistor')
|
||||||
const MigrationPersistor = require('./MigrationPersistor')
|
const MigrationPersistor = require('./MigrationPersistor')
|
||||||
const PerProjectEncryptedS3Persistor = require('./PerProjectEncryptedS3Persistor')
|
const {
|
||||||
|
PerProjectEncryptedS3Persistor,
|
||||||
|
} = require('./PerProjectEncryptedS3Persistor')
|
||||||
|
|
||||||
function getPersistor(backend, settings) {
|
function getPersistor(backend, settings) {
|
||||||
switch (backend) {
|
switch (backend) {
|
||||||
|
|
|
@ -39,7 +39,10 @@ const {
|
||||||
NotImplementedError,
|
NotImplementedError,
|
||||||
NoKEKMatchedError,
|
NoKEKMatchedError,
|
||||||
} = require('@overleaf/object-persistor/src/Errors')
|
} = require('@overleaf/object-persistor/src/Errors')
|
||||||
const PerProjectEncryptedS3Persistor = require('@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor')
|
const {
|
||||||
|
PerProjectEncryptedS3Persistor,
|
||||||
|
RootKeyEncryptionKey,
|
||||||
|
} = require('@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
|
|
||||||
describe('Filestore', function () {
|
describe('Filestore', function () {
|
||||||
|
@ -1118,33 +1121,39 @@ describe('Filestore', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('kek rotation', function () {
|
describe('kek rotation', function () {
|
||||||
const newKEK = crypto.generateKeySync('aes', { length: 256 }).export()
|
const newKEK = new RootKeyEncryptionKey(
|
||||||
const oldKEK = crypto.generateKeySync('aes', { length: 256 }).export()
|
crypto.generateKeySync('aes', { length: 256 }).export(),
|
||||||
|
Buffer.alloc(32)
|
||||||
|
)
|
||||||
|
const oldKEK = new RootKeyEncryptionKey(
|
||||||
|
crypto.generateKeySync('aes', { length: 256 }).export(),
|
||||||
|
Buffer.alloc(32)
|
||||||
|
)
|
||||||
const migrationStep0 = new PerProjectEncryptedS3Persistor({
|
const migrationStep0 = new PerProjectEncryptedS3Persistor({
|
||||||
...s3SSECConfig(),
|
...s3SSECConfig(),
|
||||||
automaticallyRotateDEKEncryption: false,
|
automaticallyRotateDEKEncryption: false,
|
||||||
async getKeyEncryptionKeys() {
|
async getRootKeyEncryptionKeys() {
|
||||||
return [oldKEK] // only old key
|
return [oldKEK] // only old key
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const migrationStep1 = new PerProjectEncryptedS3Persistor({
|
const migrationStep1 = new PerProjectEncryptedS3Persistor({
|
||||||
...s3SSECConfig(),
|
...s3SSECConfig(),
|
||||||
automaticallyRotateDEKEncryption: false,
|
automaticallyRotateDEKEncryption: false,
|
||||||
async getKeyEncryptionKeys() {
|
async getRootKeyEncryptionKeys() {
|
||||||
return [oldKEK, newKEK] // new key as fallback
|
return [oldKEK, newKEK] // new key as fallback
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const migrationStep2 = new PerProjectEncryptedS3Persistor({
|
const migrationStep2 = new PerProjectEncryptedS3Persistor({
|
||||||
...s3SSECConfig(),
|
...s3SSECConfig(),
|
||||||
automaticallyRotateDEKEncryption: true, // <- different compared to partiallyRotated
|
automaticallyRotateDEKEncryption: true, // <- different compared to partiallyRotated
|
||||||
async getKeyEncryptionKeys() {
|
async getRootKeyEncryptionKeys() {
|
||||||
return [newKEK, oldKEK] // old keys as fallback
|
return [newKEK, oldKEK] // old keys as fallback
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const migrationStep3 = new PerProjectEncryptedS3Persistor({
|
const migrationStep3 = new PerProjectEncryptedS3Persistor({
|
||||||
...s3SSECConfig(),
|
...s3SSECConfig(),
|
||||||
automaticallyRotateDEKEncryption: true,
|
automaticallyRotateDEKEncryption: true,
|
||||||
async getKeyEncryptionKeys() {
|
async getRootKeyEncryptionKeys() {
|
||||||
return [newKEK] // only new key
|
return [newKEK] // only new key
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -2,6 +2,9 @@ const fs = require('fs')
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const https = require('https')
|
const https = require('https')
|
||||||
|
const {
|
||||||
|
RootKeyEncryptionKey,
|
||||||
|
} = require('@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor')
|
||||||
|
|
||||||
// use functions to get a fresh copy, not a reference, each time
|
// use functions to get a fresh copy, not a reference, each time
|
||||||
function s3BaseConfig() {
|
function s3BaseConfig() {
|
||||||
|
@ -26,15 +29,21 @@ function s3Config() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const S3SSECKeys = [crypto.generateKeySync('aes', { length: 256 }).export()]
|
const S3SSECKeys = [
|
||||||
|
new RootKeyEncryptionKey(
|
||||||
|
crypto.generateKeySync('aes', { length: 256 }).export(),
|
||||||
|
Buffer.alloc(32)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
function s3SSECConfig() {
|
function s3SSECConfig() {
|
||||||
return {
|
return {
|
||||||
...s3Config(),
|
...s3Config(),
|
||||||
ignoreErrorsFromDEKReEncryption: false,
|
ignoreErrorsFromDEKReEncryption: false,
|
||||||
automaticallyRotateDEKEncryption: true,
|
automaticallyRotateDEKEncryption: true,
|
||||||
pathIsProjectFolder(_bucketName, path) {
|
pathToProjectFolder(_bucketName, path) {
|
||||||
return !!path.match(/^[a-f0-9]+\/$/)
|
const [projectFolder] = path.match(/^[a-f0-9]+\//)
|
||||||
|
return projectFolder
|
||||||
},
|
},
|
||||||
pathToDataEncryptionKeyPath(_bucketName, path) {
|
pathToDataEncryptionKeyPath(_bucketName, path) {
|
||||||
const [projectFolder] = path.match(/^[a-f0-9]+\//)
|
const [projectFolder] = path.match(/^[a-f0-9]+\//)
|
||||||
|
@ -43,7 +52,7 @@ function s3SSECConfig() {
|
||||||
path: Path.join(projectFolder, 'dek'),
|
path: Path.join(projectFolder, 'dek'),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getKeyEncryptionKeys() {
|
async getRootKeyEncryptionKeys() {
|
||||||
return S3SSECKeys
|
return S3SSECKeys
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue