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:
Jakob Ackermann 2024-11-08 09:32:09 +01:00 committed by Copybot
parent bec73ddfae
commit a67560c26b
4 changed files with 109 additions and 28 deletions

View file

@ -15,6 +15,9 @@ const {
const logger = require('@overleaf/logger')
const generateKey = promisify(Crypto.generateKey)
const hkdf = promisify(Crypto.hkdf)
const AES256_KEY_LENGTH = 32
/**
* @typedef {import('aws-sdk').AWSError} AWSError
@ -25,8 +28,8 @@ const generateKey = promisify(Crypto.generateKey)
* @property {boolean} automaticallyRotateDEKEncryption
* @property {boolean} ignoreErrorsFromDEKReEncryption
* @property {(bucketName: string, path: string) => {bucketName: string, path: string}} pathToDataEncryptionKeyPath
* @property {(bucketName: string, path: string) => boolean} pathIsProjectFolder
* @property {() => Promise<Array<Buffer>>} getKeyEncryptionKeys
* @property {(bucketName: string, path: string) => string} pathToProjectFolder
* @property {() => Promise<Array<RootKeyEncryptionKey>>} getRootKeyEncryptionKeys
*/
/**
@ -52,10 +55,47 @@ function isForbiddenError(err) {
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 {
/** @type {Settings} */
#settings
/** @type {Promise<Array<SSECOptions>>} */
/** @type {Promise<Array<RootKeyEncryptionKey>>} */
#availableKeyEncryptionKeysPromise
/**
@ -65,10 +105,10 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
super(settings)
this.#settings = settings
this.#availableKeyEncryptionKeysPromise = this.#settings
.getKeyEncryptionKeys()
.then(keysAsBuffer => {
if (keysAsBuffer.length === 0) throw new Error('no kek provided')
return keysAsBuffer.map(buffer => new SSECOptions(buffer))
.getRootKeyEncryptionKeys()
.then(rootKEKs => {
if (rootKEKs.length === 0) throw new Error('no root kek provided')
return rootKEKs
})
}
@ -76,9 +116,16 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
await this.#availableKeyEncryptionKeysPromise
}
async #getCurrentKeyEncryptionKey() {
const available = await this.#availableKeyEncryptionKeysPromise
return available[0]
/**
* @param {string} bucketName
* @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) {
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 {
return await super.getObjectSize(dekPath.bucketName, dekPath.path, {
ssecOptions,
@ -138,7 +188,7 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
{
// Do not overwrite any objects if already created
ifNoneMatch: '*',
ssecOptions: await this.#getCurrentKeyEncryptionKey(),
ssecOptions: await this.#getCurrentKeyEncryptionKey(bucketName, path),
}
)
return new SSECOptions(dataEncryptionKey)
@ -153,7 +203,10 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
const dekPath = this.#settings.pathToDataEncryptionKeyPath(bucketName, path)
let res
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 {
res = await super.getObjectStream(dekPath.bucketName, dekPath.path, {
ssecOptions,
@ -171,12 +224,16 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
await Stream.promises.pipeline(res, buf)
if (kekIndex !== 0 && this.#settings.automaticallyRotateDEKEncryption) {
const ssecOptions = await this.#getCurrentKeyEncryptionKey(
bucketName,
path
)
try {
await super.sendStream(
dekPath.bucketName,
dekPath.path,
Stream.Readable.from([buf.getContents()]),
{ ssecOptions: await this.#getCurrentKeyEncryptionKey() }
{ ssecOptions }
)
} catch (err) {
if (this.#settings.ignoreErrorsFromDEKReEncryption) {
@ -252,7 +309,7 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
async deleteDirectory(bucketName, path, continuationToken) {
// Note: Listing/Deleting a prefix does not require SSE-C credentials.
await super.deleteDirectory(bucketName, path, continuationToken)
if (this.#settings.pathIsProjectFolder(bucketName, path)) {
if (this.#settings.pathToProjectFolder(bucketName, path) === path) {
const dekPath = this.#settings.pathToDataEncryptionKeyPath(
bucketName,
path
@ -357,4 +414,8 @@ class CachedPerProjectEncryptedS3Persistor {
}
}
module.exports = PerProjectEncryptedS3Persistor
module.exports = {
PerProjectEncryptedS3Persistor,
CachedPerProjectEncryptedS3Persistor,
RootKeyEncryptionKey,
}

View file

@ -4,7 +4,9 @@ const GcsPersistor = require('./GcsPersistor')
const { S3Persistor } = require('./S3Persistor')
const FSPersistor = require('./FSPersistor')
const MigrationPersistor = require('./MigrationPersistor')
const PerProjectEncryptedS3Persistor = require('./PerProjectEncryptedS3Persistor')
const {
PerProjectEncryptedS3Persistor,
} = require('./PerProjectEncryptedS3Persistor')
function getPersistor(backend, settings) {
switch (backend) {

View file

@ -39,7 +39,10 @@ const {
NotImplementedError,
NoKEKMatchedError,
} = 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')
describe('Filestore', function () {
@ -1118,33 +1121,39 @@ describe('Filestore', function () {
})
describe('kek rotation', function () {
const newKEK = crypto.generateKeySync('aes', { length: 256 }).export()
const oldKEK = crypto.generateKeySync('aes', { length: 256 }).export()
const newKEK = new RootKeyEncryptionKey(
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({
...s3SSECConfig(),
automaticallyRotateDEKEncryption: false,
async getKeyEncryptionKeys() {
async getRootKeyEncryptionKeys() {
return [oldKEK] // only old key
},
})
const migrationStep1 = new PerProjectEncryptedS3Persistor({
...s3SSECConfig(),
automaticallyRotateDEKEncryption: false,
async getKeyEncryptionKeys() {
async getRootKeyEncryptionKeys() {
return [oldKEK, newKEK] // new key as fallback
},
})
const migrationStep2 = new PerProjectEncryptedS3Persistor({
...s3SSECConfig(),
automaticallyRotateDEKEncryption: true, // <- different compared to partiallyRotated
async getKeyEncryptionKeys() {
async getRootKeyEncryptionKeys() {
return [newKEK, oldKEK] // old keys as fallback
},
})
const migrationStep3 = new PerProjectEncryptedS3Persistor({
...s3SSECConfig(),
automaticallyRotateDEKEncryption: true,
async getKeyEncryptionKeys() {
async getRootKeyEncryptionKeys() {
return [newKEK] // only new key
},
})

View file

@ -2,6 +2,9 @@ const fs = require('fs')
const Path = require('path')
const crypto = require('crypto')
const https = require('https')
const {
RootKeyEncryptionKey,
} = require('@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor')
// use functions to get a fresh copy, not a reference, each time
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() {
return {
...s3Config(),
ignoreErrorsFromDEKReEncryption: false,
automaticallyRotateDEKEncryption: true,
pathIsProjectFolder(_bucketName, path) {
return !!path.match(/^[a-f0-9]+\/$/)
pathToProjectFolder(_bucketName, path) {
const [projectFolder] = path.match(/^[a-f0-9]+\//)
return projectFolder
},
pathToDataEncryptionKeyPath(_bucketName, path) {
const [projectFolder] = path.match(/^[a-f0-9]+\//)
@ -43,7 +52,7 @@ function s3SSECConfig() {
path: Path.join(projectFolder, 'dek'),
}
},
async getKeyEncryptionKeys() {
async getRootKeyEncryptionKeys() {
return S3SSECKeys
},
}