mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #21543 from overleaf/jpa-s3-seec-kek-rotation
[object-persistor] s3SSEC: add support for (automatic) KEK rotation GitOrigin-RevId: 315082e894c74e276a8efbc46b41ec7e102f9010
This commit is contained in:
parent
859901ac0c
commit
bec73ddfae
6 changed files with 268 additions and 33 deletions
|
@ -6,6 +6,7 @@ class ReadError extends OError {}
|
||||||
class SettingsError extends OError {}
|
class SettingsError extends OError {}
|
||||||
class NotImplementedError extends OError {}
|
class NotImplementedError extends OError {}
|
||||||
class AlreadyWrittenError extends OError {}
|
class AlreadyWrittenError extends OError {}
|
||||||
|
class NoKEKMatchedError extends OError {}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
|
@ -14,4 +15,5 @@ module.exports = {
|
||||||
SettingsError,
|
SettingsError,
|
||||||
NotImplementedError,
|
NotImplementedError,
|
||||||
AlreadyWrittenError,
|
AlreadyWrittenError,
|
||||||
|
NoKEKMatchedError,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,31 +1,62 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
const Stream = require('stream')
|
|
||||||
const { promisify } = require('util')
|
|
||||||
const Crypto = require('crypto')
|
const Crypto = require('crypto')
|
||||||
|
const Stream = require('stream')
|
||||||
|
const fs = require('fs')
|
||||||
|
const { promisify } = require('util')
|
||||||
const { WritableBuffer } = require('@overleaf/stream-utils')
|
const { WritableBuffer } = require('@overleaf/stream-utils')
|
||||||
const { S3Persistor, SSECOptions } = require('./S3Persistor.js')
|
const { S3Persistor, SSECOptions } = require('./S3Persistor.js')
|
||||||
|
const {
|
||||||
|
AlreadyWrittenError,
|
||||||
|
NoKEKMatchedError,
|
||||||
|
NotFoundError,
|
||||||
|
NotImplementedError,
|
||||||
|
ReadError,
|
||||||
|
} = require('./Errors')
|
||||||
|
const logger = require('@overleaf/logger')
|
||||||
|
|
||||||
const generateKey = promisify(Crypto.generateKey)
|
const generateKey = promisify(Crypto.generateKey)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Settings
|
* @typedef {import('aws-sdk').AWSError} AWSError
|
||||||
* @property {(bucketName: string, path: string) => {bucketName: string, path: string}} pathToDataEncryptionKeyPath
|
|
||||||
* @property {(bucketName: string, path: string) => boolean} pathIsProjectFolder
|
|
||||||
* @property {() => Promise<Buffer>} getKeyEncryptionKey
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const {
|
/**
|
||||||
NotFoundError,
|
* @typedef {Object} Settings
|
||||||
NotImplementedError,
|
* @property {boolean} automaticallyRotateDEKEncryption
|
||||||
AlreadyWrittenError,
|
* @property {boolean} ignoreErrorsFromDEKReEncryption
|
||||||
} = require('./Errors')
|
* @property {(bucketName: string, path: string) => {bucketName: string, path: string}} pathToDataEncryptionKeyPath
|
||||||
const fs = require('fs')
|
* @property {(bucketName: string, path: string) => boolean} pathIsProjectFolder
|
||||||
|
* @property {() => Promise<Array<Buffer>>} getKeyEncryptionKeys
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to make TS happy when accessing error properties
|
||||||
|
* AWSError is not an actual class, so we cannot use instanceof.
|
||||||
|
* @param {any} err
|
||||||
|
* @return {err is AWSError}
|
||||||
|
*/
|
||||||
|
function isAWSError(err) {
|
||||||
|
return !!err
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} err
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
function isForbiddenError(err) {
|
||||||
|
if (!err || !(err instanceof ReadError || err instanceof NotFoundError)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const cause = err.cause
|
||||||
|
if (!isAWSError(cause)) return false
|
||||||
|
return cause.statusCode === 403
|
||||||
|
}
|
||||||
|
|
||||||
class PerProjectEncryptedS3Persistor extends S3Persistor {
|
class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||||
/** @type Settings */
|
/** @type {Settings} */
|
||||||
#settings
|
#settings
|
||||||
/** @type Promise<SSECOptions> */
|
/** @type {Promise<Array<SSECOptions>>} */
|
||||||
#keyEncryptionKeyOptions
|
#availableKeyEncryptionKeysPromise
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Settings} settings
|
* @param {Settings} settings
|
||||||
|
@ -33,13 +64,21 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||||
constructor(settings) {
|
constructor(settings) {
|
||||||
super(settings)
|
super(settings)
|
||||||
this.#settings = settings
|
this.#settings = settings
|
||||||
this.#keyEncryptionKeyOptions = this.#settings
|
this.#availableKeyEncryptionKeysPromise = this.#settings
|
||||||
.getKeyEncryptionKey()
|
.getKeyEncryptionKeys()
|
||||||
.then(keyAsBuffer => new SSECOptions(keyAsBuffer))
|
.then(keysAsBuffer => {
|
||||||
|
if (keysAsBuffer.length === 0) throw new Error('no kek provided')
|
||||||
|
return keysAsBuffer.map(buffer => new SSECOptions(buffer))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureKeyEncryptionKeyLoaded() {
|
async ensureKeyEncryptionKeysLoaded() {
|
||||||
await this.#keyEncryptionKeyOptions
|
await this.#availableKeyEncryptionKeysPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async #getCurrentKeyEncryptionKey() {
|
||||||
|
const available = await this.#availableKeyEncryptionKeysPromise
|
||||||
|
return available[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -48,9 +87,17 @@ 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) {
|
||||||
|
try {
|
||||||
return await super.getObjectSize(dekPath.bucketName, dekPath.path, {
|
return await super.getObjectSize(dekPath.bucketName, dekPath.path, {
|
||||||
ssecOptions: await this.#keyEncryptionKeyOptions,
|
ssecOptions,
|
||||||
})
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (isForbiddenError(err)) continue
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new NoKEKMatchedError('no kek matched')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -91,7 +138,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.#keyEncryptionKeyOptions,
|
ssecOptions: await this.#getCurrentKeyEncryptionKey(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return new SSECOptions(dataEncryptionKey)
|
return new SSECOptions(dataEncryptionKey)
|
||||||
|
@ -104,11 +151,42 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||||
*/
|
*/
|
||||||
async #getExistingDataEncryptionKeyOptions(bucketName, path) {
|
async #getExistingDataEncryptionKeyOptions(bucketName, path) {
|
||||||
const dekPath = this.#settings.pathToDataEncryptionKeyPath(bucketName, path)
|
const dekPath = this.#settings.pathToDataEncryptionKeyPath(bucketName, path)
|
||||||
const res = await super.getObjectStream(dekPath.bucketName, dekPath.path, {
|
let res
|
||||||
ssecOptions: await this.#keyEncryptionKeyOptions,
|
let kekIndex = 0
|
||||||
|
for (const ssecOptions of await this.#availableKeyEncryptionKeysPromise) {
|
||||||
|
try {
|
||||||
|
res = await super.getObjectStream(dekPath.bucketName, dekPath.path, {
|
||||||
|
ssecOptions,
|
||||||
})
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (isForbiddenError(err)) {
|
||||||
|
kekIndex++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!res) throw new NoKEKMatchedError('no kek matched')
|
||||||
const buf = new WritableBuffer()
|
const buf = new WritableBuffer()
|
||||||
await Stream.promises.pipeline(res, buf)
|
await Stream.promises.pipeline(res, buf)
|
||||||
|
|
||||||
|
if (kekIndex !== 0 && this.#settings.automaticallyRotateDEKEncryption) {
|
||||||
|
try {
|
||||||
|
await super.sendStream(
|
||||||
|
dekPath.bucketName,
|
||||||
|
dekPath.path,
|
||||||
|
Stream.Readable.from([buf.getContents()]),
|
||||||
|
{ ssecOptions: await this.#getCurrentKeyEncryptionKey() }
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if (this.#settings.ignoreErrorsFromDEKReEncryption) {
|
||||||
|
logger.warn({ err, ...dekPath }, 'failed to persist re-encrypted DEK')
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new SSECOptions(buf.getContents())
|
return new SSECOptions(buf.getContents())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -179,7 +179,8 @@ class S3Persistor extends AbstractPersistor {
|
||||||
case 200: // full response
|
case 200: // full response
|
||||||
case 206: // partial response
|
case 206: // partial response
|
||||||
return resolve(undefined)
|
return resolve(undefined)
|
||||||
case 403: // AccessDenied is handled the same as NoSuchKey
|
case 403: // AccessDenied
|
||||||
|
return // handled by stream.on('error') handler below
|
||||||
case 404: // NoSuchKey
|
case 404: // NoSuchKey
|
||||||
return reject(new NotFoundError('not found'))
|
return reject(new NotFoundError('not found'))
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -93,6 +93,12 @@ describe('S3PersistorTests', function () {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.err) return ReadStream.emit('error', this.err)
|
if (this.err) return ReadStream.emit('error', this.err)
|
||||||
this.emit('httpHeaders', this.statusCode)
|
this.emit('httpHeaders', this.statusCode)
|
||||||
|
if (this.statusCode === 403) {
|
||||||
|
ReadStream.emit('error', S3AccessDeniedError)
|
||||||
|
}
|
||||||
|
if (this.statusCode === 404) {
|
||||||
|
ReadStream.emit('error', S3NotFoundError)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return ReadStream
|
return ReadStream
|
||||||
}
|
}
|
||||||
|
@ -359,7 +365,7 @@ describe('S3PersistorTests', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('wraps the error', function () {
|
it('wraps the error', function () {
|
||||||
expect(error.cause).to.exist
|
expect(error.cause).to.equal(S3AccessDeniedError)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('stores the bucket and key in the error', function () {
|
it('stores the bucket and key in the error', function () {
|
||||||
|
|
|
@ -32,12 +32,15 @@ process.on('unhandledRejection', e => {
|
||||||
|
|
||||||
// store settings for multiple backends, so that we can test each one.
|
// store settings for multiple backends, so that we can test each one.
|
||||||
// fs will always be available - add others if they are configured
|
// fs will always be available - add others if they are configured
|
||||||
const { BackendSettings, s3Config } = require('./TestConfig')
|
const { BackendSettings, s3Config, s3SSECConfig } = require('./TestConfig')
|
||||||
const {
|
const {
|
||||||
AlreadyWrittenError,
|
AlreadyWrittenError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
NotImplementedError,
|
NotImplementedError,
|
||||||
|
NoKEKMatchedError,
|
||||||
} = require('@overleaf/object-persistor/src/Errors')
|
} = require('@overleaf/object-persistor/src/Errors')
|
||||||
|
const PerProjectEncryptedS3Persistor = require('@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
describe('Filestore', function () {
|
describe('Filestore', function () {
|
||||||
this.timeout(1000 * 10)
|
this.timeout(1000 * 10)
|
||||||
|
@ -1015,12 +1018,19 @@ describe('Filestore', function () {
|
||||||
expect(dataEncryptionKeySize).to.equal(32)
|
expect(dataEncryptionKeySize).to.equal(32)
|
||||||
})
|
})
|
||||||
|
|
||||||
let fileId1, fileId2, fileKey1, fileKey2, fileUrl1, fileUrl2
|
let fileId1,
|
||||||
|
fileId2,
|
||||||
|
fileKey1,
|
||||||
|
fileKey2,
|
||||||
|
fileKeyOtherProject,
|
||||||
|
fileUrl1,
|
||||||
|
fileUrl2
|
||||||
beforeEach('prepare ids', function () {
|
beforeEach('prepare ids', function () {
|
||||||
fileId1 = new ObjectId().toString()
|
fileId1 = new ObjectId().toString()
|
||||||
fileId2 = new ObjectId().toString()
|
fileId2 = new ObjectId().toString()
|
||||||
fileKey1 = `${projectId}/${fileId1}`
|
fileKey1 = `${projectId}/${fileId1}`
|
||||||
fileKey2 = `${projectId}/${fileId2}`
|
fileKey2 = `${projectId}/${fileId2}`
|
||||||
|
fileKeyOtherProject = `${new ObjectId().toString()}/${new ObjectId().toString()}`
|
||||||
fileUrl1 = `${filestoreUrl}/project/${projectId}/file/${fileId1}`
|
fileUrl1 = `${filestoreUrl}/project/${projectId}/file/${fileId1}`
|
||||||
fileUrl2 = `${filestoreUrl}/project/${projectId}/file/${fileId2}`
|
fileUrl2 = `${filestoreUrl}/project/${projectId}/file/${fileId2}`
|
||||||
})
|
})
|
||||||
|
@ -1107,6 +1117,139 @@ describe('Filestore', function () {
|
||||||
await checkGET2()
|
await checkGET2()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('kek rotation', function () {
|
||||||
|
const newKEK = crypto.generateKeySync('aes', { length: 256 }).export()
|
||||||
|
const oldKEK = crypto.generateKeySync('aes', { length: 256 }).export()
|
||||||
|
const migrationStep0 = new PerProjectEncryptedS3Persistor({
|
||||||
|
...s3SSECConfig(),
|
||||||
|
automaticallyRotateDEKEncryption: false,
|
||||||
|
async getKeyEncryptionKeys() {
|
||||||
|
return [oldKEK] // only old key
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const migrationStep1 = new PerProjectEncryptedS3Persistor({
|
||||||
|
...s3SSECConfig(),
|
||||||
|
automaticallyRotateDEKEncryption: false,
|
||||||
|
async getKeyEncryptionKeys() {
|
||||||
|
return [oldKEK, newKEK] // new key as fallback
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const migrationStep2 = new PerProjectEncryptedS3Persistor({
|
||||||
|
...s3SSECConfig(),
|
||||||
|
automaticallyRotateDEKEncryption: true, // <- different compared to partiallyRotated
|
||||||
|
async getKeyEncryptionKeys() {
|
||||||
|
return [newKEK, oldKEK] // old keys as fallback
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const migrationStep3 = new PerProjectEncryptedS3Persistor({
|
||||||
|
...s3SSECConfig(),
|
||||||
|
automaticallyRotateDEKEncryption: true,
|
||||||
|
async getKeyEncryptionKeys() {
|
||||||
|
return [newKEK] // only new key
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function checkWrites(
|
||||||
|
fileKey,
|
||||||
|
writer,
|
||||||
|
readersSuccess,
|
||||||
|
readersFailed
|
||||||
|
) {
|
||||||
|
const content = Math.random().toString()
|
||||||
|
await writer.sendStream(
|
||||||
|
Settings.filestore.stores.user_files,
|
||||||
|
fileKey,
|
||||||
|
Stream.Readable.from([content])
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const persistor of readersSuccess) {
|
||||||
|
await TestHelper.expectPersistorToHaveFile(
|
||||||
|
persistor,
|
||||||
|
backendSettings.stores.user_files,
|
||||||
|
fileKey,
|
||||||
|
content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const persistor of readersFailed) {
|
||||||
|
await expect(
|
||||||
|
TestHelper.expectPersistorToHaveFile(
|
||||||
|
persistor,
|
||||||
|
backendSettings.stores.user_files,
|
||||||
|
fileKey,
|
||||||
|
content
|
||||||
|
)
|
||||||
|
).to.be.rejectedWith(NoKEKMatchedError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stages = [
|
||||||
|
{
|
||||||
|
name: 'stage 0 - [old]',
|
||||||
|
prev: migrationStep0,
|
||||||
|
cur: migrationStep0,
|
||||||
|
fail: [migrationStep3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'stage 1 - [old,new]',
|
||||||
|
prev: migrationStep0,
|
||||||
|
cur: migrationStep1,
|
||||||
|
fail: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'stage 2 - [new,old]',
|
||||||
|
prev: migrationStep1,
|
||||||
|
cur: migrationStep2,
|
||||||
|
fail: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'stage 3 - [new]',
|
||||||
|
prev: migrationStep2,
|
||||||
|
cur: migrationStep3,
|
||||||
|
fail: [migrationStep0],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const { name, prev, cur, fail } of stages) {
|
||||||
|
describe(name, function () {
|
||||||
|
it('can read old writes', async function () {
|
||||||
|
await checkWrites(fileKey1, prev, [prev, cur], fail)
|
||||||
|
await checkWrites(fileKey2, prev, [prev, cur], fail) // check again after access
|
||||||
|
await checkWrites(fileKeyOtherProject, prev, [prev, cur], fail)
|
||||||
|
})
|
||||||
|
it('can read new writes', async function () {
|
||||||
|
await checkWrites(fileKey1, prev, [prev, cur], fail)
|
||||||
|
await checkWrites(fileKey2, cur, [prev, cur], fail) // check again after access
|
||||||
|
await checkWrites(fileKeyOtherProject, cur, [prev, cur], fail)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('full migration', function () {
|
||||||
|
it('can read old writes if rotated in sequence', async function () {
|
||||||
|
await checkWrites(
|
||||||
|
fileKey1,
|
||||||
|
migrationStep0,
|
||||||
|
[
|
||||||
|
migrationStep0,
|
||||||
|
migrationStep1,
|
||||||
|
migrationStep2, // migrates
|
||||||
|
migrationStep3,
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('cannot read/write if not rotated', async function () {
|
||||||
|
await checkWrites(
|
||||||
|
fileKey1,
|
||||||
|
migrationStep0,
|
||||||
|
[migrationStep0],
|
||||||
|
[migrationStep3]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
let s3Client
|
let s3Client
|
||||||
before('create s3Client', function () {
|
before('create s3Client', function () {
|
||||||
const cfg = s3Config()
|
const cfg = s3Config()
|
||||||
|
|
|
@ -26,9 +26,13 @@ function s3Config() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const S3SSECKeys = [crypto.generateKeySync('aes', { length: 256 }).export()]
|
||||||
|
|
||||||
function s3SSECConfig() {
|
function s3SSECConfig() {
|
||||||
return {
|
return {
|
||||||
...s3Config(),
|
...s3Config(),
|
||||||
|
ignoreErrorsFromDEKReEncryption: false,
|
||||||
|
automaticallyRotateDEKEncryption: true,
|
||||||
pathIsProjectFolder(_bucketName, path) {
|
pathIsProjectFolder(_bucketName, path) {
|
||||||
return !!path.match(/^[a-f0-9]+\/$/)
|
return !!path.match(/^[a-f0-9]+\/$/)
|
||||||
},
|
},
|
||||||
|
@ -39,8 +43,8 @@ function s3SSECConfig() {
|
||||||
path: Path.join(projectFolder, 'dek'),
|
path: Path.join(projectFolder, 'dek'),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getKeyEncryptionKey() {
|
async getKeyEncryptionKeys() {
|
||||||
return crypto.generateKeySync('aes', { length: 256 }).export()
|
return S3SSECKeys
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -177,4 +181,5 @@ checkForUnexpectedTestFile()
|
||||||
module.exports = {
|
module.exports = {
|
||||||
BackendSettings,
|
BackendSettings,
|
||||||
s3Config,
|
s3Config,
|
||||||
|
s3SSECConfig,
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue