PerProjectEncryptedS3Persistor.js 13 KB

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