PerProjectEncryptedS3Persistor.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. // @ts-check
  2. const Stream = require('stream')
  3. const { promisify } = require('util')
  4. const Crypto = require('crypto')
  5. const { WritableBuffer } = require('@overleaf/stream-utils')
  6. const { S3Persistor, SSECOptions } = require('./S3Persistor.js')
  7. const generateKey = promisify(Crypto.generateKey)
  8. /**
  9. * @typedef {Object} Settings
  10. * @property {(bucketName: string, path: string) => {bucketName: string, path: string}} pathToDataEncryptionKeyPath
  11. * @property {(bucketName: string, path: string) => boolean} pathIsProjectFolder
  12. * @property {() => Promise<Buffer>} getKeyEncryptionKey
  13. */
  14. const {
  15. NotFoundError,
  16. NotImplementedError,
  17. AlreadyWrittenError,
  18. } = require('./Errors')
  19. const fs = require('fs')
  20. class PerProjectEncryptedS3Persistor extends S3Persistor {
  21. /** @type Settings */
  22. #settings
  23. /** @type Promise<SSECOptions> */
  24. #keyEncryptionKeyOptions
  25. /**
  26. * @param {Settings} settings
  27. */
  28. constructor(settings) {
  29. super(settings)
  30. this.#settings = settings
  31. this.#keyEncryptionKeyOptions = this.#settings
  32. .getKeyEncryptionKey()
  33. .then(keyAsBuffer => new SSECOptions(keyAsBuffer))
  34. }
  35. async ensureKeyEncryptionKeyLoaded() {
  36. await this.#keyEncryptionKeyOptions
  37. }
  38. /**
  39. * @param {string} bucketName
  40. * @param {string} path
  41. */
  42. async getDataEncryptionKeySize(bucketName, path) {
  43. const dekPath = this.#settings.pathToDataEncryptionKeyPath(bucketName, path)
  44. return await super.getObjectSize(dekPath.bucketName, dekPath.path, {
  45. ssecOptions: await this.#keyEncryptionKeyOptions,
  46. })
  47. }
  48. /**
  49. * @param {string} bucketName
  50. * @param {string} path
  51. * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
  52. */
  53. async forProject(bucketName, path) {
  54. return new CachedPerProjectEncryptedS3Persistor(
  55. this,
  56. await this.#getDataEncryptionKeyOptions(bucketName, path)
  57. )
  58. }
  59. /**
  60. * @param {string} bucketName
  61. * @param {string} path
  62. * @return {Promise<void>}
  63. */
  64. async generateDataEncryptionKey(bucketName, path) {
  65. await this.#generateDataEncryptionKeyOptions(bucketName, path)
  66. }
  67. /**
  68. * @param {string} bucketName
  69. * @param {string} path
  70. * @return {Promise<SSECOptions>}
  71. */
  72. async #generateDataEncryptionKeyOptions(bucketName, path) {
  73. const dataEncryptionKey = (
  74. await generateKey('aes', { length: 256 })
  75. ).export()
  76. const dekPath = this.#settings.pathToDataEncryptionKeyPath(bucketName, path)
  77. await super.sendStream(
  78. dekPath.bucketName,
  79. dekPath.path,
  80. Stream.Readable.from([dataEncryptionKey]),
  81. {
  82. // Do not overwrite any objects if already created
  83. ifNoneMatch: '*',
  84. ssecOptions: await this.#keyEncryptionKeyOptions,
  85. }
  86. )
  87. return new SSECOptions(dataEncryptionKey)
  88. }
  89. /**
  90. * @param {string} bucketName
  91. * @param {string} path
  92. * @return {Promise<SSECOptions>}
  93. */
  94. async #getExistingDataEncryptionKeyOptions(bucketName, path) {
  95. const dekPath = this.#settings.pathToDataEncryptionKeyPath(bucketName, path)
  96. const res = await super.getObjectStream(dekPath.bucketName, dekPath.path, {
  97. ssecOptions: await this.#keyEncryptionKeyOptions,
  98. })
  99. const buf = new WritableBuffer()
  100. await Stream.promises.pipeline(res, buf)
  101. return new SSECOptions(buf.getContents())
  102. }
  103. /**
  104. * @param {string} bucketName
  105. * @param {string} path
  106. * @return {Promise<SSECOptions>}
  107. */
  108. async #getDataEncryptionKeyOptions(bucketName, path) {
  109. try {
  110. return await this.#getExistingDataEncryptionKeyOptions(bucketName, path)
  111. } catch (err) {
  112. if (err instanceof NotFoundError) {
  113. try {
  114. return await this.#generateDataEncryptionKeyOptions(bucketName, path)
  115. } catch (err2) {
  116. if (err2 instanceof AlreadyWrittenError) {
  117. // Concurrent initial write
  118. return await this.#getExistingDataEncryptionKeyOptions(
  119. bucketName,
  120. path
  121. )
  122. }
  123. throw err2
  124. }
  125. }
  126. throw err
  127. }
  128. }
  129. async sendStream(bucketName, path, sourceStream, opts = {}) {
  130. const ssecOptions =
  131. opts.ssecOptions ||
  132. (await this.#getDataEncryptionKeyOptions(bucketName, path))
  133. return await super.sendStream(bucketName, path, sourceStream, {
  134. ...opts,
  135. ssecOptions,
  136. })
  137. }
  138. async getObjectStream(bucketName, path, opts = {}) {
  139. const ssecOptions =
  140. opts.ssecOptions ||
  141. (await this.#getExistingDataEncryptionKeyOptions(bucketName, path))
  142. return await super.getObjectStream(bucketName, path, {
  143. ...opts,
  144. ssecOptions,
  145. })
  146. }
  147. async getObjectSize(bucketName, path, opts = {}) {
  148. const ssecOptions =
  149. opts.ssecOptions ||
  150. (await this.#getExistingDataEncryptionKeyOptions(bucketName, path))
  151. return await super.getObjectSize(bucketName, path, { ...opts, ssecOptions })
  152. }
  153. async directorySize(bucketName, path, continuationToken) {
  154. // Note: Listing a bucket does not require SSE-C credentials.
  155. return await super.directorySize(bucketName, path, continuationToken)
  156. }
  157. async deleteDirectory(bucketName, path, continuationToken) {
  158. // Note: Listing/Deleting a prefix does not require SSE-C credentials.
  159. await super.deleteDirectory(bucketName, path, continuationToken)
  160. if (this.#settings.pathIsProjectFolder(bucketName, path)) {
  161. const dekPath = this.#settings.pathToDataEncryptionKeyPath(
  162. bucketName,
  163. path
  164. )
  165. await super.deleteObject(dekPath.bucketName, dekPath.path)
  166. }
  167. }
  168. async getObjectMd5Hash(bucketName, path, opts = {}) {
  169. // The ETag in object metadata is not the MD5 content hash, skip the HEAD request.
  170. opts = { ...opts, etagIsNotMD5: true }
  171. return await super.getObjectMd5Hash(bucketName, path, opts)
  172. }
  173. async copyObject(bucketName, sourcePath, destinationPath, opts = {}) {
  174. const ssecOptions =
  175. opts.ssecOptions ||
  176. (await this.#getDataEncryptionKeyOptions(bucketName, destinationPath))
  177. const ssecSrcOptions =
  178. opts.ssecSrcOptions ||
  179. (await this.#getExistingDataEncryptionKeyOptions(bucketName, sourcePath))
  180. return await super.copyObject(bucketName, sourcePath, destinationPath, {
  181. ...opts,
  182. ssecOptions,
  183. ssecSrcOptions,
  184. })
  185. }
  186. /**
  187. * @param {string} bucketName
  188. * @param {string} path
  189. * @return {Promise<string>}
  190. */
  191. async getRedirectUrl(bucketName, path) {
  192. throw new NotImplementedError('signed links are not supported with SSE-C')
  193. }
  194. }
  195. /**
  196. * Helper class for batch updates to avoid repeated fetching of the project path.
  197. *
  198. * A general "cache" for project keys is another alternative. For now, use a helper class.
  199. */
  200. class CachedPerProjectEncryptedS3Persistor {
  201. /** @type SSECOptions */
  202. #projectKeyOptions
  203. /** @type PerProjectEncryptedS3Persistor */
  204. #parent
  205. /**
  206. * @param {PerProjectEncryptedS3Persistor} parent
  207. * @param {SSECOptions} projectKeyOptions
  208. */
  209. constructor(parent, projectKeyOptions) {
  210. this.#parent = parent
  211. this.#projectKeyOptions = projectKeyOptions
  212. }
  213. /**
  214. * @param {string} bucketName
  215. * @param {string} path
  216. * @param {string} fsPath
  217. */
  218. async sendFile(bucketName, path, fsPath) {
  219. return await this.sendStream(bucketName, path, fs.createReadStream(fsPath))
  220. }
  221. /**
  222. * @param {string} bucketName
  223. * @param {string} path
  224. * @param {NodeJS.ReadableStream} sourceStream
  225. * @param {Object} opts
  226. * @param {string} [opts.contentType]
  227. * @param {string} [opts.contentEncoding]
  228. * @param {'*'} [opts.ifNoneMatch]
  229. * @param {SSECOptions} [opts.ssecOptions]
  230. * @param {string} [opts.sourceMd5]
  231. * @return {Promise<void>}
  232. */
  233. async sendStream(bucketName, path, sourceStream, opts = {}) {
  234. return await this.#parent.sendStream(bucketName, path, sourceStream, {
  235. ...opts,
  236. ssecOptions: this.#projectKeyOptions,
  237. })
  238. }
  239. /**
  240. * @param {string} bucketName
  241. * @param {string} path
  242. * @param {Object} opts
  243. * @param {number} [opts.start]
  244. * @param {number} [opts.end]
  245. * @param {string} [opts.contentEncoding]
  246. * @param {SSECOptions} [opts.ssecOptions]
  247. * @return {Promise<NodeJS.ReadableStream>}
  248. */
  249. async getObjectStream(bucketName, path, opts = {}) {
  250. return await this.#parent.getObjectStream(bucketName, path, {
  251. ...opts,
  252. ssecOptions: this.#projectKeyOptions,
  253. })
  254. }
  255. }
  256. module.exports = PerProjectEncryptedS3Persistor