PerProjectEncryptedS3Persistor.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. // @ts-check
  2. const Crypto = require('node:crypto')
  3. const Stream = require('node:stream')
  4. const fs = require('node:fs')
  5. const { promisify } = require('node:util')
  6. const { WritableBuffer } = require('@overleaf/stream-utils')
  7. const { S3Persistor, SSECOptions } = require('./S3Persistor.js')
  8. const {
  9. AlreadyWrittenError,
  10. NoKEKMatchedError,
  11. NotFoundError,
  12. NotImplementedError,
  13. ReadError,
  14. } = require('./Errors')
  15. const logger = require('@overleaf/logger')
  16. const Path = require('node:path')
  17. const generateKey = promisify(Crypto.generateKey)
  18. const hkdf = promisify(Crypto.hkdf)
  19. const AES256_KEY_LENGTH = 32
  20. /**
  21. * @typedef {Object} Settings
  22. * @property {boolean} automaticallyRotateDEKEncryption
  23. * @property {string} dataEncryptionKeyBucketName
  24. * @property {boolean} ignoreErrorsFromDEKReEncryption
  25. * @property {(bucketName: string, path: string) => string} pathToProjectFolder
  26. * @property {() => Promise<Array<RootKeyEncryptionKey>>} getRootKeyEncryptionKeys
  27. */
  28. /**
  29. * @typedef {import('./types').ListDirectoryResult} ListDirectoryResult
  30. */
  31. /**
  32. * @param {any} err
  33. * @return {boolean}
  34. */
  35. function isForbiddenError(err) {
  36. if (!err || !(err instanceof ReadError || err instanceof NotFoundError)) {
  37. return false
  38. }
  39. // @ts-ignore
  40. return err?.cause.statusCode === 403 || err?.cause.Code === 'AccessDenied'
  41. }
  42. class RootKeyEncryptionKey {
  43. /** @type {Buffer} */
  44. #keyEncryptionKey
  45. /** @type {Buffer} */
  46. #salt
  47. /**
  48. * @param {Buffer} keyEncryptionKey
  49. * @param {Buffer} salt
  50. */
  51. constructor(keyEncryptionKey, salt) {
  52. if (keyEncryptionKey.byteLength !== AES256_KEY_LENGTH) {
  53. throw new Error(`kek is not ${AES256_KEY_LENGTH} bytes long`)
  54. }
  55. this.#keyEncryptionKey = keyEncryptionKey
  56. this.#salt = salt
  57. }
  58. /**
  59. * @param {string} prefix
  60. * @return {Promise<SSECOptions>}
  61. */
  62. async forProject(prefix) {
  63. return new SSECOptions(
  64. Buffer.from(
  65. await hkdf(
  66. 'sha256',
  67. this.#keyEncryptionKey,
  68. this.#salt,
  69. prefix,
  70. AES256_KEY_LENGTH
  71. )
  72. )
  73. )
  74. }
  75. }
  76. class PerProjectEncryptedS3Persistor extends S3Persistor {
  77. /** @type {Settings} */
  78. #settings
  79. /** @type {Promise<Array<RootKeyEncryptionKey>>} */
  80. #availableKeyEncryptionKeysPromise
  81. /**
  82. * @param {Settings} settings
  83. */
  84. constructor(settings) {
  85. if (!settings.dataEncryptionKeyBucketName) {
  86. throw new Error('settings.dataEncryptionKeyBucketName is missing')
  87. }
  88. super(settings)
  89. this.#settings = settings
  90. this.#availableKeyEncryptionKeysPromise = settings
  91. .getRootKeyEncryptionKeys()
  92. .then(rootKEKs => {
  93. if (rootKEKs.length === 0) throw new Error('no root kek provided')
  94. return rootKEKs
  95. })
  96. }
  97. async ensureKeyEncryptionKeysLoaded() {
  98. await this.#availableKeyEncryptionKeysPromise
  99. }
  100. /**
  101. * @param {string} bucketName
  102. * @param {string} path
  103. * @return {{dekPath: string, projectFolder: string}}
  104. */
  105. #buildProjectPaths(bucketName, path) {
  106. const projectFolder = this.#settings.pathToProjectFolder(bucketName, path)
  107. const dekPath = Path.join(projectFolder, 'dek')
  108. return { projectFolder, dekPath }
  109. }
  110. /**
  111. * @param {string} projectFolder
  112. * @return {Promise<SSECOptions>}
  113. */
  114. async #getCurrentKeyEncryptionKey(projectFolder) {
  115. const [currentRootKEK] = await this.#availableKeyEncryptionKeysPromise
  116. return await currentRootKEK.forProject(projectFolder)
  117. }
  118. /**
  119. * @param {string} bucketName
  120. * @param {string} path
  121. */
  122. async getDataEncryptionKeySize(bucketName, path) {
  123. const { projectFolder, dekPath } = this.#buildProjectPaths(bucketName, path)
  124. for (const rootKEK of await this.#availableKeyEncryptionKeysPromise) {
  125. const ssecOptions = await rootKEK.forProject(projectFolder)
  126. try {
  127. return await super.getObjectSize(
  128. this.#settings.dataEncryptionKeyBucketName,
  129. dekPath,
  130. { ssecOptions }
  131. )
  132. } catch (err) {
  133. if (isForbiddenError(err)) continue
  134. throw err
  135. }
  136. }
  137. throw new NoKEKMatchedError('no kek matched')
  138. }
  139. /**
  140. * @param {string} bucketName
  141. * @param {string} path
  142. * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
  143. */
  144. async forProject(bucketName, path) {
  145. return new CachedPerProjectEncryptedS3Persistor(
  146. this,
  147. await this.#getDataEncryptionKeyOptions(bucketName, path)
  148. )
  149. }
  150. /**
  151. * @param {string} bucketName
  152. * @param {string} path
  153. * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
  154. */
  155. async forProjectRO(bucketName, path) {
  156. return new CachedPerProjectEncryptedS3Persistor(
  157. this,
  158. await this.#getExistingDataEncryptionKeyOptions(bucketName, path)
  159. )
  160. }
  161. /**
  162. * @param {string} bucketName
  163. * @param {string} path
  164. * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
  165. */
  166. async generateDataEncryptionKey(bucketName, path) {
  167. return new CachedPerProjectEncryptedS3Persistor(
  168. this,
  169. await this.#generateDataEncryptionKeyOptions(bucketName, path)
  170. )
  171. }
  172. /**
  173. * @param {string} bucketName
  174. * @param {string} path
  175. * @return {Promise<SSECOptions>}
  176. */
  177. async #generateDataEncryptionKeyOptions(bucketName, path) {
  178. const dataEncryptionKey = (
  179. await generateKey('aes', { length: 256 })
  180. ).export()
  181. const { projectFolder, dekPath } = this.#buildProjectPaths(bucketName, path)
  182. await super.sendStream(
  183. this.#settings.dataEncryptionKeyBucketName,
  184. dekPath,
  185. Stream.Readable.from([dataEncryptionKey]),
  186. {
  187. // Do not overwrite any objects if already created
  188. ifNoneMatch: '*',
  189. ssecOptions: await this.#getCurrentKeyEncryptionKey(projectFolder),
  190. contentLength: 32,
  191. }
  192. )
  193. return new SSECOptions(dataEncryptionKey)
  194. }
  195. /**
  196. * @param {string} bucketName
  197. * @param {string} path
  198. * @return {Promise<SSECOptions>}
  199. */
  200. async #getExistingDataEncryptionKeyOptions(bucketName, path) {
  201. const { projectFolder, dekPath } = this.#buildProjectPaths(bucketName, path)
  202. let res
  203. let kekIndex = 0
  204. for (const rootKEK of await this.#availableKeyEncryptionKeysPromise) {
  205. const ssecOptions = await rootKEK.forProject(projectFolder)
  206. try {
  207. res = await super.getObjectStream(
  208. this.#settings.dataEncryptionKeyBucketName,
  209. dekPath,
  210. { ssecOptions }
  211. )
  212. break
  213. } catch (err) {
  214. if (isForbiddenError(err)) {
  215. kekIndex++
  216. continue
  217. }
  218. throw err
  219. }
  220. }
  221. if (!res) throw new NoKEKMatchedError('no kek matched')
  222. const buf = new WritableBuffer()
  223. await Stream.promises.pipeline(res, buf)
  224. if (kekIndex !== 0 && this.#settings.automaticallyRotateDEKEncryption) {
  225. const ssecOptions = await this.#getCurrentKeyEncryptionKey(projectFolder)
  226. try {
  227. await super.sendStream(
  228. this.#settings.dataEncryptionKeyBucketName,
  229. dekPath,
  230. Stream.Readable.from([buf.getContents()]),
  231. { ssecOptions }
  232. )
  233. } catch (err) {
  234. if (this.#settings.ignoreErrorsFromDEKReEncryption) {
  235. logger.warn({ err, dekPath }, 'failed to persist re-encrypted DEK')
  236. } else {
  237. throw err
  238. }
  239. }
  240. }
  241. return new SSECOptions(buf.getContents())
  242. }
  243. /**
  244. * @param {string} bucketName
  245. * @param {string} path
  246. * @return {Promise<SSECOptions>}
  247. */
  248. async #getDataEncryptionKeyOptions(bucketName, path) {
  249. try {
  250. return await this.#getExistingDataEncryptionKeyOptions(bucketName, path)
  251. } catch (err) {
  252. if (err instanceof NotFoundError) {
  253. try {
  254. return await this.#generateDataEncryptionKeyOptions(bucketName, path)
  255. } catch (err2) {
  256. if (err2 instanceof AlreadyWrittenError) {
  257. // Concurrent initial write
  258. return await this.#getExistingDataEncryptionKeyOptions(
  259. bucketName,
  260. path
  261. )
  262. }
  263. throw err2
  264. }
  265. }
  266. throw err
  267. }
  268. }
  269. async sendStream(bucketName, path, sourceStream, opts = {}) {
  270. const ssecOptions =
  271. opts.ssecOptions ||
  272. (await this.#getDataEncryptionKeyOptions(bucketName, path))
  273. return await super.sendStream(bucketName, path, sourceStream, {
  274. ...opts,
  275. ssecOptions,
  276. })
  277. }
  278. async getObjectStream(bucketName, path, opts = {}) {
  279. const ssecOptions =
  280. opts.ssecOptions ||
  281. (await this.#getExistingDataEncryptionKeyOptions(bucketName, path))
  282. return await super.getObjectStream(bucketName, path, {
  283. ...opts,
  284. ssecOptions,
  285. })
  286. }
  287. async getObjectSize(bucketName, path, opts = {}) {
  288. const ssecOptions =
  289. opts.ssecOptions ||
  290. (await this.#getExistingDataEncryptionKeyOptions(bucketName, path))
  291. return await super.getObjectSize(bucketName, path, { ...opts, ssecOptions })
  292. }
  293. async getObjectStorageClass(bucketName, path, opts = {}) {
  294. const ssecOptions =
  295. opts.ssecOptions ||
  296. (await this.#getExistingDataEncryptionKeyOptions(bucketName, path))
  297. return await super.getObjectStorageClass(bucketName, path, {
  298. ...opts,
  299. ssecOptions,
  300. })
  301. }
  302. async directorySize(bucketName, path, continuationToken) {
  303. // Note: Listing a bucket does not require SSE-C credentials.
  304. return await super.directorySize(bucketName, path, continuationToken)
  305. }
  306. async deleteDirectory(bucketName, path, continuationToken) {
  307. // Let [Settings.pathToProjectFolder] validate the project path before deleting things.
  308. const { projectFolder, dekPath } = this.#buildProjectPaths(bucketName, path)
  309. // Note: Listing/Deleting a prefix does not require SSE-C credentials.
  310. await super.deleteDirectory(bucketName, path, continuationToken)
  311. if (projectFolder === path) {
  312. await super.deleteObject(
  313. this.#settings.dataEncryptionKeyBucketName,
  314. dekPath
  315. )
  316. }
  317. }
  318. async getObjectMd5Hash(bucketName, path, opts = {}) {
  319. // The ETag in object metadata is not the MD5 content hash, skip the HEAD request.
  320. opts = { ...opts, etagIsNotMD5: true }
  321. return await super.getObjectMd5Hash(bucketName, path, opts)
  322. }
  323. async copyObject(bucketName, sourcePath, destinationPath, opts = {}) {
  324. const ssecOptions =
  325. opts.ssecOptions ||
  326. (await this.#getDataEncryptionKeyOptions(bucketName, destinationPath))
  327. const ssecSrcOptions =
  328. opts.ssecSrcOptions ||
  329. (await this.#getExistingDataEncryptionKeyOptions(bucketName, sourcePath))
  330. return await super.copyObject(bucketName, sourcePath, destinationPath, {
  331. ...opts,
  332. ssecOptions,
  333. ssecSrcOptions,
  334. })
  335. }
  336. /**
  337. * @param {string} bucketName
  338. * @param {string} path
  339. * @return {Promise<string>}
  340. */
  341. async getRedirectUrl(bucketName, path) {
  342. throw new NotImplementedError('signed links are not supported with SSE-C')
  343. }
  344. }
  345. /**
  346. * Helper class for batch updates to avoid repeated fetching of the project path.
  347. *
  348. * A general "cache" for project keys is another alternative. For now, use a helper class.
  349. */
  350. class CachedPerProjectEncryptedS3Persistor {
  351. /** @type SSECOptions */
  352. #projectKeyOptions
  353. /** @type PerProjectEncryptedS3Persistor */
  354. #parent
  355. /**
  356. * @param {PerProjectEncryptedS3Persistor} parent
  357. * @param {SSECOptions} projectKeyOptions
  358. */
  359. constructor(parent, projectKeyOptions) {
  360. this.#parent = parent
  361. this.#projectKeyOptions = projectKeyOptions
  362. }
  363. /**
  364. * @param {string} bucketName
  365. * @param {string} path
  366. * @param {string} fsPath
  367. */
  368. async sendFile(bucketName, path, fsPath) {
  369. return await this.sendStream(bucketName, path, fs.createReadStream(fsPath))
  370. }
  371. /**
  372. *
  373. * @param {string} bucketName
  374. * @param {string} path
  375. * @return {Promise<number>}
  376. */
  377. async getObjectSize(bucketName, path) {
  378. return await this.#parent.getObjectSize(bucketName, path)
  379. }
  380. /**
  381. *
  382. * @param {string} bucketName
  383. * @param {string} path
  384. * @return {Promise<ListDirectoryResult>}
  385. */
  386. async listDirectory(bucketName, path) {
  387. return await this.#parent.listDirectory(bucketName, path)
  388. }
  389. /**
  390. * @param {string} bucketName
  391. * @param {string} path
  392. * @param {NodeJS.ReadableStream} sourceStream
  393. * @param {Object} opts
  394. * @param {string} [opts.contentType]
  395. * @param {string} [opts.contentEncoding]
  396. * @param {number} [opts.contentLength]
  397. * @param {'*'} [opts.ifNoneMatch]
  398. * @param {SSECOptions} [opts.ssecOptions]
  399. * @param {string} [opts.sourceMd5]
  400. * @return {Promise<void>}
  401. */
  402. async sendStream(bucketName, path, sourceStream, opts = {}) {
  403. return await this.#parent.sendStream(bucketName, path, sourceStream, {
  404. ...opts,
  405. ssecOptions: this.#projectKeyOptions,
  406. })
  407. }
  408. /**
  409. * @param {string} bucketName
  410. * @param {string} path
  411. * @param {Object} opts
  412. * @param {number} [opts.start]
  413. * @param {number} [opts.end]
  414. * @param {boolean} [opts.autoGunzip]
  415. * @param {SSECOptions} [opts.ssecOptions]
  416. * @return {Promise<NodeJS.ReadableStream>}
  417. */
  418. async getObjectStream(bucketName, path, opts = {}) {
  419. return await this.#parent.getObjectStream(bucketName, path, {
  420. ...opts,
  421. ssecOptions: this.#projectKeyOptions,
  422. })
  423. }
  424. }
  425. module.exports = {
  426. PerProjectEncryptedS3Persistor,
  427. CachedPerProjectEncryptedS3Persistor,
  428. RootKeyEncryptionKey,
  429. }