PerProjectEncryptedS3Persistor.js 15 KB

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