| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468 |
- // @ts-check
- const Crypto = require('node:crypto')
- const Stream = require('node:stream')
- const fs = require('node:fs')
- const { promisify } = require('node:util')
- const { WritableBuffer } = require('@overleaf/stream-utils')
- const { S3Persistor, SSECOptions } = require('./S3Persistor.js')
- const {
- AlreadyWrittenError,
- NoKEKMatchedError,
- NotFoundError,
- NotImplementedError,
- ReadError,
- } = require('./Errors')
- const logger = require('@overleaf/logger')
- const Path = require('node:path')
- const generateKey = promisify(Crypto.generateKey)
- const hkdf = promisify(Crypto.hkdf)
- const AES256_KEY_LENGTH = 32
- /**
- * @typedef {Object} Settings
- * @property {boolean} automaticallyRotateDEKEncryption
- * @property {string} dataEncryptionKeyBucketName
- * @property {boolean} ignoreErrorsFromDEKReEncryption
- * @property {(bucketName: string, path: string) => string} pathToProjectFolder
- * @property {() => Promise<Array<RootKeyEncryptionKey>>} getRootKeyEncryptionKeys
- */
- /**
- * @typedef {import('./types').ListDirectoryResult} ListDirectoryResult
- */
- /**
- * @param {any} err
- * @return {boolean}
- */
- function isForbiddenError(err) {
- if (!err || !(err instanceof ReadError || err instanceof NotFoundError)) {
- return false
- }
- // @ts-ignore
- return err?.cause.statusCode === 403 || err?.cause.Code === 'AccessDenied'
- }
- 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<RootKeyEncryptionKey>>} */
- #availableKeyEncryptionKeysPromise
- /**
- * @param {Settings} settings
- */
- constructor(settings) {
- if (!settings.dataEncryptionKeyBucketName) {
- throw new Error('settings.dataEncryptionKeyBucketName is missing')
- }
- super(settings)
- this.#settings = settings
- this.#availableKeyEncryptionKeysPromise = settings
- .getRootKeyEncryptionKeys()
- .then(rootKEKs => {
- if (rootKEKs.length === 0) throw new Error('no root kek provided')
- return rootKEKs
- })
- }
- async ensureKeyEncryptionKeysLoaded() {
- await this.#availableKeyEncryptionKeysPromise
- }
- /**
- * @param {string} bucketName
- * @param {string} path
- * @return {{dekPath: string, projectFolder: string}}
- */
- #buildProjectPaths(bucketName, path) {
- const projectFolder = this.#settings.pathToProjectFolder(bucketName, path)
- const dekPath = Path.join(projectFolder, 'dek')
- return { projectFolder, dekPath }
- }
- /**
- * @param {string} projectFolder
- * @return {Promise<SSECOptions>}
- */
- async #getCurrentKeyEncryptionKey(projectFolder) {
- const [currentRootKEK] = await this.#availableKeyEncryptionKeysPromise
- return await currentRootKEK.forProject(projectFolder)
- }
- /**
- * @param {string} bucketName
- * @param {string} path
- */
- async getDataEncryptionKeySize(bucketName, path) {
- const { projectFolder, dekPath } = this.#buildProjectPaths(bucketName, path)
- for (const rootKEK of await this.#availableKeyEncryptionKeysPromise) {
- const ssecOptions = await rootKEK.forProject(projectFolder)
- try {
- return await super.getObjectSize(
- this.#settings.dataEncryptionKeyBucketName,
- dekPath,
- { ssecOptions }
- )
- } catch (err) {
- if (isForbiddenError(err)) continue
- throw err
- }
- }
- throw new NoKEKMatchedError('no kek matched')
- }
- /**
- * @param {string} bucketName
- * @param {string} path
- * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
- */
- async forProject(bucketName, path) {
- return new CachedPerProjectEncryptedS3Persistor(
- this,
- await this.#getDataEncryptionKeyOptions(bucketName, path)
- )
- }
- /**
- * @param {string} bucketName
- * @param {string} path
- * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
- */
- async forProjectRO(bucketName, path) {
- return new CachedPerProjectEncryptedS3Persistor(
- this,
- await this.#getExistingDataEncryptionKeyOptions(bucketName, path)
- )
- }
- /**
- * @param {string} bucketName
- * @param {string} path
- * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
- */
- async generateDataEncryptionKey(bucketName, path) {
- return new CachedPerProjectEncryptedS3Persistor(
- this,
- await this.#generateDataEncryptionKeyOptions(bucketName, path)
- )
- }
- /**
- * @param {string} bucketName
- * @param {string} path
- * @return {Promise<SSECOptions>}
- */
- async #generateDataEncryptionKeyOptions(bucketName, path) {
- const dataEncryptionKey = (
- await generateKey('aes', { length: 256 })
- ).export()
- const { projectFolder, dekPath } = this.#buildProjectPaths(bucketName, path)
- await super.sendStream(
- this.#settings.dataEncryptionKeyBucketName,
- dekPath,
- Stream.Readable.from([dataEncryptionKey]),
- {
- // Do not overwrite any objects if already created
- ifNoneMatch: '*',
- ssecOptions: await this.#getCurrentKeyEncryptionKey(projectFolder),
- contentLength: 32,
- }
- )
- return new SSECOptions(dataEncryptionKey)
- }
- /**
- * @param {string} bucketName
- * @param {string} path
- * @return {Promise<SSECOptions>}
- */
- async #getExistingDataEncryptionKeyOptions(bucketName, path) {
- const { projectFolder, dekPath } = this.#buildProjectPaths(bucketName, path)
- let res
- let kekIndex = 0
- for (const rootKEK of await this.#availableKeyEncryptionKeysPromise) {
- const ssecOptions = await rootKEK.forProject(projectFolder)
- try {
- res = await super.getObjectStream(
- this.#settings.dataEncryptionKeyBucketName,
- dekPath,
- { ssecOptions }
- )
- break
- } catch (err) {
- if (isForbiddenError(err)) {
- kekIndex++
- continue
- }
- throw err
- }
- }
- if (!res) throw new NoKEKMatchedError('no kek matched')
- const buf = new WritableBuffer()
- await Stream.promises.pipeline(res, buf)
- if (kekIndex !== 0 && this.#settings.automaticallyRotateDEKEncryption) {
- const ssecOptions = await this.#getCurrentKeyEncryptionKey(projectFolder)
- try {
- await super.sendStream(
- this.#settings.dataEncryptionKeyBucketName,
- dekPath,
- Stream.Readable.from([buf.getContents()]),
- { ssecOptions }
- )
- } catch (err) {
- if (this.#settings.ignoreErrorsFromDEKReEncryption) {
- logger.warn({ err, dekPath }, 'failed to persist re-encrypted DEK')
- } else {
- throw err
- }
- }
- }
- return new SSECOptions(buf.getContents())
- }
- /**
- * @param {string} bucketName
- * @param {string} path
- * @return {Promise<SSECOptions>}
- */
- async #getDataEncryptionKeyOptions(bucketName, path) {
- try {
- return await this.#getExistingDataEncryptionKeyOptions(bucketName, path)
- } catch (err) {
- if (err instanceof NotFoundError) {
- try {
- return await this.#generateDataEncryptionKeyOptions(bucketName, path)
- } catch (err2) {
- if (err2 instanceof AlreadyWrittenError) {
- // Concurrent initial write
- return await this.#getExistingDataEncryptionKeyOptions(
- bucketName,
- path
- )
- }
- throw err2
- }
- }
- throw err
- }
- }
- async sendStream(bucketName, path, sourceStream, opts = {}) {
- const ssecOptions =
- opts.ssecOptions ||
- (await this.#getDataEncryptionKeyOptions(bucketName, path))
- return await super.sendStream(bucketName, path, sourceStream, {
- ...opts,
- ssecOptions,
- })
- }
- async getObjectStream(bucketName, path, opts = {}) {
- const ssecOptions =
- opts.ssecOptions ||
- (await this.#getExistingDataEncryptionKeyOptions(bucketName, path))
- return await super.getObjectStream(bucketName, path, {
- ...opts,
- ssecOptions,
- })
- }
- async getObjectSize(bucketName, path, opts = {}) {
- const ssecOptions =
- opts.ssecOptions ||
- (await this.#getExistingDataEncryptionKeyOptions(bucketName, path))
- return await super.getObjectSize(bucketName, path, { ...opts, ssecOptions })
- }
- async getObjectStorageClass(bucketName, path, opts = {}) {
- const ssecOptions =
- opts.ssecOptions ||
- (await this.#getExistingDataEncryptionKeyOptions(bucketName, path))
- return await super.getObjectStorageClass(bucketName, path, {
- ...opts,
- ssecOptions,
- })
- }
- async directorySize(bucketName, path, continuationToken) {
- // Note: Listing a bucket does not require SSE-C credentials.
- return await super.directorySize(bucketName, path, continuationToken)
- }
- async deleteDirectory(bucketName, path, continuationToken) {
- // Let [Settings.pathToProjectFolder] validate the project path before deleting things.
- const { projectFolder, dekPath } = this.#buildProjectPaths(bucketName, path)
- // Note: Listing/Deleting a prefix does not require SSE-C credentials.
- await super.deleteDirectory(bucketName, path, continuationToken)
- if (projectFolder === path) {
- await super.deleteObject(
- this.#settings.dataEncryptionKeyBucketName,
- dekPath
- )
- }
- }
- async getObjectMd5Hash(bucketName, path, opts = {}) {
- // The ETag in object metadata is not the MD5 content hash, skip the HEAD request.
- opts = { ...opts, etagIsNotMD5: true }
- return await super.getObjectMd5Hash(bucketName, path, opts)
- }
- async copyObject(bucketName, sourcePath, destinationPath, opts = {}) {
- const ssecOptions =
- opts.ssecOptions ||
- (await this.#getDataEncryptionKeyOptions(bucketName, destinationPath))
- const ssecSrcOptions =
- opts.ssecSrcOptions ||
- (await this.#getExistingDataEncryptionKeyOptions(bucketName, sourcePath))
- return await super.copyObject(bucketName, sourcePath, destinationPath, {
- ...opts,
- ssecOptions,
- ssecSrcOptions,
- })
- }
- /**
- * @param {string} bucketName
- * @param {string} path
- * @return {Promise<string>}
- */
- async getRedirectUrl(bucketName, path) {
- throw new NotImplementedError('signed links are not supported with SSE-C')
- }
- }
- /**
- * Helper class for batch updates to avoid repeated fetching of the project path.
- *
- * A general "cache" for project keys is another alternative. For now, use a helper class.
- */
- class CachedPerProjectEncryptedS3Persistor {
- /** @type SSECOptions */
- #projectKeyOptions
- /** @type PerProjectEncryptedS3Persistor */
- #parent
- /**
- * @param {PerProjectEncryptedS3Persistor} parent
- * @param {SSECOptions} projectKeyOptions
- */
- constructor(parent, projectKeyOptions) {
- this.#parent = parent
- this.#projectKeyOptions = projectKeyOptions
- }
- /**
- * @param {string} bucketName
- * @param {string} path
- * @param {string} fsPath
- */
- async sendFile(bucketName, path, fsPath) {
- return await this.sendStream(bucketName, path, fs.createReadStream(fsPath))
- }
- /**
- *
- * @param {string} bucketName
- * @param {string} path
- * @return {Promise<number>}
- */
- async getObjectSize(bucketName, path) {
- return await this.#parent.getObjectSize(bucketName, path)
- }
- /**
- *
- * @param {string} bucketName
- * @param {string} path
- * @return {Promise<ListDirectoryResult>}
- */
- async listDirectory(bucketName, path) {
- return await this.#parent.listDirectory(bucketName, path)
- }
- /**
- * @param {string} bucketName
- * @param {string} path
- * @param {NodeJS.ReadableStream} sourceStream
- * @param {Object} opts
- * @param {string} [opts.contentType]
- * @param {string} [opts.contentEncoding]
- * @param {number} [opts.contentLength]
- * @param {'*'} [opts.ifNoneMatch]
- * @param {SSECOptions} [opts.ssecOptions]
- * @param {string} [opts.sourceMd5]
- * @return {Promise<void>}
- */
- async sendStream(bucketName, path, sourceStream, opts = {}) {
- return await this.#parent.sendStream(bucketName, path, sourceStream, {
- ...opts,
- ssecOptions: this.#projectKeyOptions,
- })
- }
- /**
- * @param {string} bucketName
- * @param {string} path
- * @param {Object} opts
- * @param {number} [opts.start]
- * @param {number} [opts.end]
- * @param {boolean} [opts.autoGunzip]
- * @param {SSECOptions} [opts.ssecOptions]
- * @return {Promise<NodeJS.ReadableStream>}
- */
- async getObjectStream(bucketName, path, opts = {}) {
- return await this.#parent.getObjectStream(bucketName, path, {
- ...opts,
- ssecOptions: this.#projectKeyOptions,
- })
- }
- }
- module.exports = {
- PerProjectEncryptedS3Persistor,
- CachedPerProjectEncryptedS3Persistor,
- RootKeyEncryptionKey,
- }
|