active_storage_encryption 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +12 -0
  3. data/.github/workflows/ci.yml +75 -0
  4. data/.gitignore +14 -0
  5. data/.ruby-version +1 -0
  6. data/.standard.yml +1 -0
  7. data/Appraisals +2 -0
  8. data/Gemfile +9 -0
  9. data/README.md +2 -0
  10. data/active_storage_encryption.gemspec +47 -0
  11. data/gemfiles/rails_7.gemfile +1 -0
  12. data/gemfiles/rails_7.gemfile.lock +78 -1
  13. data/gemfiles/rails_8.gemfile +1 -0
  14. data/gemfiles/rails_8.gemfile.lock +78 -1
  15. data/lib/active_storage/service/encrypted_gcs_service.rb +10 -0
  16. data/lib/active_storage_encryption/encrypted_blob_proxy_controller.rb +1 -1
  17. data/lib/active_storage_encryption/encrypted_gcs_service.rb +158 -0
  18. data/lib/active_storage_encryption/engine.rb +4 -0
  19. data/lib/active_storage_encryption/overrides.rb +33 -5
  20. data/lib/active_storage_encryption/private_url_policy.rb +1 -1
  21. data/lib/active_storage_encryption/version.rb +1 -1
  22. data/lib/active_storage_encryption.rb +1 -0
  23. data/lib/generators/add_encryption_key_to_active_storage_blobs.rb.erb +9 -0
  24. data/lib/generators/install_generator.rb +25 -0
  25. data/test/dummy/app/assets/images/.keep +0 -0
  26. data/test/dummy/app/controllers/concerns/.keep +0 -0
  27. data/test/dummy/app/models/concerns/.keep +0 -0
  28. data/test/dummy/app/models/user.rb +5 -0
  29. data/test/dummy/config/environments/test.rb +2 -0
  30. data/test/dummy/config/storage.yml +3 -0
  31. data/test/dummy/db/migrate/20250428093315_create_users.rb +7 -0
  32. data/test/dummy/db/schema.rb +6 -3
  33. data/test/dummy/lib/assets/.keep +0 -0
  34. data/test/dummy/log/.keep +0 -0
  35. data/test/integration/.keep +0 -0
  36. data/test/lib/encrypted_gcs_service_test.rb +201 -0
  37. data/test/lib/encrypted_s3_service_test.rb +4 -1
  38. data/test/lib/overrides_test.rb +349 -0
  39. metadata +52 -127
  40. data/test/dummy/log/development.log +0 -304
  41. data/test/dummy/log/test.log +0 -66969
  42. data/test/dummy/storage/0a/mt/0amtaps713liftrtbxt9h998epz4 +0 -0
  43. data/test/dummy/storage/0b/93/0b93pygovuunam1a3ovzwmrbuw2x +0 -0
  44. data/test/dummy/storage/0m/3s/0m3s7r3nboblijr1jxlnvm3p3l4b +0 -0
  45. data/test/dummy/storage/0o/9s/0o9s4ctbpu757qh7ucyony0itek4 +0 -0
  46. data/test/dummy/storage/1e/q6/1eq646og0wazgfw7bwjqz2uem0g4 +0 -0
  47. data/test/dummy/storage/1n/o3/1no30cpwrm727bm6arvb7zxagdg1 +0 -0
  48. data/test/dummy/storage/1x/6w/1x6wsoq3pew17reztwax78lrr3hc +0 -0
  49. data/test/dummy/storage/28/de/28deswrv89c9f2tk7dz1l5uovd4r +0 -0
  50. data/test/dummy/storage/2h/sd/2hsd1mh20c6os2nzyoicfyymhwev +0 -0
  51. data/test/dummy/storage/2t/ni/2tnidhdk4c6cj0tnw3jiw88dgs4g +0 -0
  52. data/test/dummy/storage/2v/e0/2ve0555nluisy2el5cf4txzgae3j +0 -0
  53. data/test/dummy/storage/2z/c5/2zc5mj8g0o9l7mfnim0vs4v48xd6 +0 -0
  54. data/test/dummy/storage/34/xc/34xc9hk74dm9227d6mhgfcfxl4ue +0 -0
  55. data/test/dummy/storage/3z/0t/3z0tnve7ivrq0qyrvfhfzztjhjqs +0 -0
  56. data/test/dummy/storage/49/14/4914188q1dptpw4po91cp54f32bg +0 -0
  57. data/test/dummy/storage/4c/74/4c7412lfz0pm2ocg6u01h67bnsch +0 -0
  58. data/test/dummy/storage/52/qf/52qfbgjlf3gor3agsyrt09t19o55 +0 -0
  59. data/test/dummy/storage/57/go/57gok1uc4ebc3ugrjrje4lpe1ram +0 -0
  60. data/test/dummy/storage/5f/dv/5fdvt6tu1mkyajbz4hbxbw6fpt9w +0 -0
  61. data/test/dummy/storage/5x/b7/5xb7zzi66fi5f6yrn09pq4ogb9wo +0 -0
  62. data/test/dummy/storage/6m/vr/6mvr1fr5it125tm4vahjw6bv9wkz +0 -0
  63. data/test/dummy/storage/7b/hb/7bhbdxqn67lape1f49jqfktcei4n +0 -0
  64. data/test/dummy/storage/7n/4v/7n4vpm1q14y4qffc4jj78m036gtw +0 -0
  65. data/test/dummy/storage/7q/ku/7qkufbjwbbqwnf89uciosleixnew +0 -0
  66. data/test/dummy/storage/8l/5v/8l5vb4o02hx46s5qohfn5to945p3 +0 -0
  67. data/test/dummy/storage/8q/pu/8qpun3f8vzl7auxajvqyq8f48ngw +0 -0
  68. data/test/dummy/storage/8w/ag/8wag4ptmox207h7mobamk0tcebwx +0 -0
  69. data/test/dummy/storage/8w/v8/8wv8lrhsw4s2r6guh1csd3jd89ii +0 -0
  70. data/test/dummy/storage/9b/c6/9bc6wlpfnqdywpnxgeoin3w9b5ch +0 -0
  71. data/test/dummy/storage/9l/wk/9lwkt21k5iburdaitbwliw7krtwt +0 -0
  72. data/test/dummy/storage/9p/0v/9p0vgfw3l2854k7so3rp33rmyh7p +0 -0
  73. data/test/dummy/storage/9r/sy/9rsya3r6syft34qz24g1h4u4qq44 +0 -0
  74. data/test/dummy/storage/9s/es/9seslusr46xjf3mfzq10hkp13kc1 +0 -0
  75. data/test/dummy/storage/9t/nv/9tnvn5v52fkvurpgszf4gco78t5h +0 -0
  76. data/test/dummy/storage/9u/to/9utokgxyu6xyovandu7pjhogoaqp +0 -0
  77. data/test/dummy/storage/9w/a4/9wa4c20p4dvm1cd5thnv9f7ei13w +0 -0
  78. data/test/dummy/storage/at/kg/atkgs5gwz2xdv9lvqftsg6p7gcpu +0 -0
  79. data/test/dummy/storage/at/qo/atqomgf3rpb2f6e1tq1yn2xqzojv +0 -0
  80. data/test/dummy/storage/ba/lq/balqtije6kf82ht4lr70ajaae9kc +0 -0
  81. data/test/dummy/storage/bf/i1/bfi1ij9rygr6lkx1r0lhgi8o5smx +0 -0
  82. data/test/dummy/storage/bg/ye/bgyenotrv3aj6lk88edwv0c41pfj +0 -0
  83. data/test/dummy/storage/bu/xe/buxed4b1l78kcax53fa37awm9ywk +0 -0
  84. data/test/dummy/storage/d2/c1/d2c11nhikb474oq3q7so0xbhukvj +0 -0
  85. data/test/dummy/storage/development.sqlite3 +0 -0
  86. data/test/dummy/storage/dk/hy/dkhybxn2o27a8xgvfhsxpgqxa1zf +0 -0
  87. data/test/dummy/storage/e7/2n/e72nz5cz3wf6qvh4dw4qfnw6ucog +0 -0
  88. data/test/dummy/storage/eo/4q/eo4qn68m7al0ehhe0s23ycuzkjto +0 -0
  89. data/test/dummy/storage/ew/8s/ew8sejdsx8ddmrzkvfa37ebz1ts1 +0 -0
  90. data/test/dummy/storage/f8/q1/f8q1kpg2tou8ru0afj8d2gy6ym5p +0 -0
  91. data/test/dummy/storage/fr/55/fr558uhp1k93jzhb4butyi2ry51t +0 -0
  92. data/test/dummy/storage/g4/nh/g4nhx1zxbeiegqpgn8ppsl1yhm0t +0 -0
  93. data/test/dummy/storage/gg/r5/ggr51egxhqfh4w5eluzs47qceb76 +0 -0
  94. data/test/dummy/storage/gh/ua/ghuaagralqmjy8rkbwmuv3010lvs +0 -0
  95. data/test/dummy/storage/gx/uh/gxuhmf52ufli3m7ng8irp8ghxa1v +0 -0
  96. data/test/dummy/storage/h0/m1/h0m1emy251xus1d9qh6u25dzy18o +0 -0
  97. data/test/dummy/storage/hh/kc/hhkc2q8paptyvhw2m5hlwylhtfo5 +0 -0
  98. data/test/dummy/storage/hq/0q/hq0q04kr6qzrp0qaee8rehcp2tzx +0 -0
  99. data/test/dummy/storage/ii/g1/iig1ge3fsjitai4g2fkq4qt369wh +0 -0
  100. data/test/dummy/storage/io/f0/iof0mv7w8qjd6m826g52pzyxedet +0 -0
  101. data/test/dummy/storage/jk/2i/jk2ifmx6ac35ubk3esufnm6bn1m1 +0 -0
  102. data/test/dummy/storage/jw/4t/jw4trdeyfkw3j8z70xcnr9a7gqe5 +0 -0
  103. data/test/dummy/storage/ke/k2/kek24leksglm1rs2a78mfmot0p3s +0 -0
  104. data/test/dummy/storage/kh/6d/kh6doaxxwxiyes0yqz2dmmpajkzv +0 -0
  105. data/test/dummy/storage/kj/7n/kj7nookjhisagd80z8hlv3wn50am +0 -0
  106. data/test/dummy/storage/kq/lf/kqlf5udtrgrk4v55qodxyt6i68p8 +0 -0
  107. data/test/dummy/storage/ky/33/ky334jbo8eb08pj9qbe919iz91mh +0 -0
  108. data/test/dummy/storage/lt/zw/ltzw4lur2bheit1273ogpfzhv7j1 +0 -0
  109. data/test/dummy/storage/m2/ve/m2vejmyttn1ium81dopppom6vum6 +0 -0
  110. data/test/dummy/storage/m8/d4/m8d4r9iauedq8wlpvnx1f3ou0jwg +0 -0
  111. data/test/dummy/storage/m9/ee/m9eetioklzatyff94gq0vn1cga1n +0 -0
  112. data/test/dummy/storage/ma/v0/mav084zvmyoh1a8i7dcwqy2aaoi9 +0 -0
  113. data/test/dummy/storage/mg/pa/mgpauiu02i28j3poef65k3q0gfpw +0 -0
  114. data/test/dummy/storage/mm/8g/mm8gp5evncb1ol1lj2jlmra2ixij +0 -0
  115. data/test/dummy/storage/mm/d2/mmd21x8c1amgnidzw0wowiwug4g3 +0 -0
  116. data/test/dummy/storage/n2/qr/n2qro0y9heko9cwxlf10wiqiipsw +0 -0
  117. data/test/dummy/storage/n8/b7/n8b7b7qgu6jtw577dnn10jrrmszs +0 -0
  118. data/test/dummy/storage/n8/p2/n8p2ine0qqhphn09kqtxco4y7g0a +0 -0
  119. data/test/dummy/storage/nk/vh/nkvhgk7snpdy2ak6k02htxx9swp7 +0 -0
  120. data/test/dummy/storage/nn/s0/nns0nggo0x645ytco52adnsi4myp +0 -0
  121. data/test/dummy/storage/nu/kz/nukzl7cckkzh68i7kyjkm9mzw7c0 +0 -0
  122. data/test/dummy/storage/nv/8v/nv8vyoghcde1yr1bjpsw4327qt7s +0 -0
  123. data/test/dummy/storage/of/on/ofonhf1gs26k3dpj6o7b0ktzfowh +0 -0
  124. data/test/dummy/storage/pl/pf/plpfs59hvdoogj9gdweqta36csqn +0 -0
  125. data/test/dummy/storage/q5/g5/q5g55ekmscu10pzfw6q4syigt81g +0 -0
  126. data/test/dummy/storage/q5/kc/q5kcr9twyb9v4mh31pay0t7nkuwu +0 -0
  127. data/test/dummy/storage/qa/xd/qaxdngi74r52ahqg1pz8hjddeajc +0 -0
  128. data/test/dummy/storage/r7/5v/r75vadn34ak53vinylgnfdl1s8rt +0 -0
  129. data/test/dummy/storage/rj/rg/rjrghnyzyvxpkjw1a57mrloz72x1 +0 -0
  130. data/test/dummy/storage/se/h7/seh7eorfoanpp6de62pubv7kyu1a +0 -0
  131. data/test/dummy/storage/sj/i1/sji1oj12soz2fcjcoz0gejvzo8to +0 -0
  132. data/test/dummy/storage/sn/2r/sn2rku9thay4hbcbt926an69maku +0 -0
  133. data/test/dummy/storage/sw/jm/swjmbmxou3tnarcirxc6gdycxh91 +0 -0
  134. data/test/dummy/storage/sz/mq/szmqlydvpgqaw7p3v0wh444wtcif +0 -0
  135. data/test/dummy/storage/test.sqlite3 +0 -0
  136. data/test/dummy/storage/tg/by/tgbyrdvg94ivhhy2z59e8l9fod10 +0 -0
  137. data/test/dummy/storage/u5/vm/u5vmz08tuayqggd436et8fiaeml1 +0 -0
  138. data/test/dummy/storage/u6/pf/u6pf4yky0vbmvid3fa3lm4lre68g +0 -0
  139. data/test/dummy/storage/ub/ql/ubqlmlilt8ujgdpngcm1zae41kgy +0 -0
  140. data/test/dummy/storage/un/29/un29e9khqism72ag27ojccmn5sds +0 -0
  141. data/test/dummy/storage/ux/ns/uxnsvuk4rr1p67n1oq6tmraz0gaw +0 -0
  142. data/test/dummy/storage/v1/qo/v1qor0zxg3lctk9mbwyos3oag9gj +0 -0
  143. data/test/dummy/storage/v8/ok/v8okmd7374w1obna13a7anllx2vu +0 -0
  144. data/test/dummy/storage/vd/tf/vdtfmz2ctis3dr1r35do9bow2xj5 +0 -0
  145. data/test/dummy/storage/vo/dg/vodgq1inccnujjt3auber7tt8w8o +0 -0
  146. data/test/dummy/storage/vp/oe/vpoeiq00tf9pk0jcjlccomkju1zc +0 -0
  147. data/test/dummy/storage/vu/kg/vukgoj6qf96bhealui2yaeyn4n72 +0 -0
  148. data/test/dummy/storage/w7/2z/w72zoqu7v6v6jp0tpy671dcbvpow +0 -0
  149. data/test/dummy/storage/wa/3f/wa3fncsnozc6n4xfu32gw34geqcd +0 -0
  150. data/test/dummy/storage/wy/ix/wyixbqx3f6a4agb8bjhrtpblpaua +0 -0
  151. data/test/dummy/storage/xd/st/xdsttma3tqt7mex0vhp1vsm3dq16 +0 -0
  152. data/test/dummy/storage/xv/ej/xvejm2e064bnpunx3nmktaqs0x90 +0 -0
  153. data/test/dummy/storage/xx/py/xxpyyodssq2xmp57qrtvuw0wchwk +0 -0
  154. data/test/dummy/storage/xz/ik/xzikejc5sohi3zexa93s9xmg4jst +0 -0
  155. data/test/dummy/storage/y4/g8/y4g8teo86blcv0zysa2d2jawvk6i +0 -0
  156. data/test/dummy/storage/y9/58/y958xli6aoktx1ehuyjc1k8dcbzv +0 -0
  157. data/test/dummy/storage/yj/lw/yjlw8bf70iujb16deja8ae43rqbc +0 -0
  158. data/test/dummy/storage/z3/qy/z3qyb9avbucwhxa8909rpfued0y5 +0 -0
  159. data/test/dummy/storage/zr/wu/zrwudcg4kgo7r0jemszuzok8grqp +0 -0
  160. data/test/dummy/tmp/local_secret.txt +0 -1
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage/service/gcs_service"
4
+ require "google/cloud/storage/service"
5
+
6
+ class ActiveStorageEncryption::EncryptedGCSService < ActiveStorage::Service::GCSService
7
+ include ActiveStorageEncryption::PrivateUrlPolicy
8
+ GCS_ENCRYPTION_KEY_LENGTH_BYTES = 32 # google wants to get a 32 byte key
9
+
10
+ def encrypted? = true
11
+
12
+ def public? = false
13
+
14
+ def service_name
15
+ # ActiveStorage::Service::DiskService => Disk
16
+ # Overridden because in Rails 8 this is "self.class.name.split("::").third.remove("Service")"
17
+ self.class.name.split("::").last.remove("Service")
18
+ end
19
+
20
+ def upload(key, io, encryption_key: nil, checksum: nil, content_type: nil, disposition: nil, filename: nil, custom_metadata: {})
21
+ instrument :upload, key: key, checksum: checksum do
22
+ # GCS's signed URLs don't include params such as response-content-type response-content_disposition
23
+ # in the signature, which means an attacker can modify them and bypass our effort to force these to
24
+ # binary and attachment when the file's content type requires it. The only way to force them is to
25
+ # store them as object's metadata.
26
+ content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
27
+ bucket.create_file(io, key, md5: checksum, cache_control: @config[:cache_control], content_type: content_type, content_disposition: content_disposition, metadata: custom_metadata, encryption_key: derive_service_encryption_key(encryption_key))
28
+ rescue Google::Cloud::InvalidArgumentError => e
29
+ raise ActiveStorage::IntegrityError, e
30
+ end
31
+ end
32
+
33
+ def url_for_direct_upload(key, expires_in:, checksum:, encryption_key:, content_type: nil, custom_metadata: {}, filename: nil, **)
34
+ instrument :url, key: key do |payload|
35
+ headers = headers_for_direct_upload(key, checksum:, encryption_key:, content_type:, filename:, custom_metadata:)
36
+
37
+ version = :v4
38
+
39
+ args = {
40
+ content_md5: checksum,
41
+ expires: expires_in,
42
+ headers: headers,
43
+ method: "PUT",
44
+ version: version
45
+ }
46
+
47
+ if @config[:iam]
48
+ args[:issuer] = issuer
49
+ args[:signer] = signer
50
+ end
51
+
52
+ generated_url = bucket.signed_url(key, **args)
53
+
54
+ payload[:url] = generated_url
55
+
56
+ generated_url
57
+ end
58
+ end
59
+
60
+ def headers_for_direct_upload(key, checksum:, encryption_key:, filename: nil, disposition: nil, content_type: nil, custom_metadata: {}, **)
61
+ headers = {
62
+ "Content-Type" => content_type,
63
+ "Content-MD5" => checksum, # Not strictly required, but it ensures the file bytes we upload match what we want. This way google will error when we upload garbage.
64
+ **gcs_encryption_key_headers(derive_service_encryption_key(encryption_key)),
65
+ **custom_metadata_headers(custom_metadata)
66
+ }
67
+ headers["Content-Disposition"] = content_disposition_with(type: disposition, filename: filename) if filename
68
+
69
+ if @config[:cache_control].present?
70
+ headers["Cache-Control"] = @config[:cache_control]
71
+ end
72
+ headers
73
+ end
74
+
75
+ def download(key, encryption_key: nil, &block)
76
+ if block_given?
77
+ instrument :streaming_download, key: key do
78
+ stream(key, encryption_key: encryption_key, &block)
79
+ end
80
+ else
81
+ instrument :download, key: key do
82
+ file_for(key).download(encryption_key: derive_service_encryption_key(encryption_key)).string
83
+ rescue Google::Cloud::NotFoundError => e
84
+ raise ActiveStorage::FileNotFoundError, e
85
+ end
86
+ end
87
+ end
88
+
89
+ def download_chunk(key, range, encryption_key: nil)
90
+ instrument :download_chunk, key: key, range: range do
91
+ file_for(key).download(range: range, encryption_key: derive_service_encryption_key(encryption_key)).string
92
+ rescue Google::Cloud::NotFoundError => e
93
+ raise ActiveStorage::FileNotFoundError, e
94
+ end
95
+ end
96
+
97
+ # Reads the file for the given key in chunks, yielding each to the block.
98
+ def stream(key, encryption_key: nil)
99
+ file = file_for(key, skip_lookup: false)
100
+
101
+ chunk_size = 5.megabytes
102
+ offset = 0
103
+
104
+ raise ActiveStorage::FileNotFoundError unless file.present?
105
+
106
+ while offset < file.size
107
+ yield file.download(range: offset..(offset + chunk_size - 1), encryption_key: derive_service_encryption_key(encryption_key)).string
108
+ offset += chunk_size
109
+ end
110
+ end
111
+
112
+ def compose(source_keys, destination_key, encryption_key:, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
113
+ # Because we will always have a different encryption_key on a blob when created and google requires us to have the same encryption_keys on all source blobs
114
+ # we need to work this out a bit more. For now we don't need this and thus won't support it in this service.
115
+ raise NotImplementedError, "Currently composing files is not supported"
116
+ end
117
+
118
+ private
119
+
120
+ def private_url(key, expires_in:, filename:, content_type:, disposition:, encryption_key:, **remaining_options_for_streaming_url)
121
+ if private_url_policy == :require_headers
122
+ args = {
123
+ expires: expires_in,
124
+ query: {
125
+ "response-content-disposition" => content_disposition_with(type: disposition, filename: filename),
126
+ "response-content-type" => content_type
127
+ },
128
+ headers: gcs_encryption_key_headers(derive_service_encryption_key(encryption_key))
129
+ }
130
+
131
+ if @config[:iam]
132
+ args[:issuer] = issuer
133
+ args[:signer] = signer
134
+ end
135
+
136
+ file_for(key).signed_url(**args, version: :v4)
137
+ else
138
+ private_url_for_streaming_via_controller(key, expires_in:, filename:, content_type:, disposition:, encryption_key:, **remaining_options_for_streaming_url)
139
+ end
140
+ end
141
+
142
+ def public_url(key, filename:, encryption_key:, content_type: nil, disposition: :inline, **)
143
+ raise "Public URL's are disabled for this service"
144
+ end
145
+
146
+ def gcs_encryption_key_headers(key)
147
+ {
148
+ "x-goog-encryption-algorithm" => "AES256",
149
+ "x-goog-encryption-key" => Base64.strict_encode64(key),
150
+ "x-goog-encryption-key-sha256" => Digest::SHA256.base64digest(key)
151
+ }
152
+ end
153
+
154
+ def derive_service_encryption_key(blob_encryption_key)
155
+ raise ArgumentError, "The blob encryption_key must be at least #{GCS_ENCRYPTION_KEY_LENGTH_BYTES} bytes long" unless blob_encryption_key.bytesize >= GCS_ENCRYPTION_KEY_LENGTH_BYTES
156
+ blob_encryption_key[0...GCS_ENCRYPTION_KEY_LENGTH_BYTES]
157
+ end
158
+ end
@@ -3,5 +3,9 @@
3
3
  module ActiveStorageEncryption
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace ActiveStorageEncryption
6
+
7
+ generators do
8
+ require "generators/install_generator"
9
+ end
6
10
  end
7
11
  end
@@ -2,6 +2,9 @@
2
2
 
3
3
  module ActiveStorageEncryption
4
4
  module Overrides
5
+ class EncryptionKeyMissingError < StandardError
6
+ end
7
+
5
8
  module EncryptedBlobClassMethods
6
9
  def self.included base
7
10
  base.class_eval do
@@ -54,7 +57,7 @@ module ActiveStorageEncryption
54
57
  content_type ||= blobs.pluck(:content_type).compact.first
55
58
 
56
59
  new(key: key, filename: filename, content_type: content_type, metadata: metadata, byte_size: blobs.sum(&:byte_size), service_name:, encryption_key:).tap do |combined_blob|
57
- combined_blob.compose(blobs.pluck(:key))
60
+ combined_blob.compose(blobs.pluck(:key), source_encryption_keys: blobs.pluck(:encryption_key))
58
61
  combined_blob.save!
59
62
  end
60
63
  end
@@ -70,7 +73,8 @@ module ActiveStorageEncryption
70
73
 
71
74
  def service_url_for_direct_upload(expires_in: ActiveStorage.service_urls_expire_in)
72
75
  if service_encrypted?
73
- raise "No encryption key present" unless encryption_key
76
+ ensure_encryption_key_set!
77
+
74
78
  service.url_for_direct_upload(key, expires_in: expires_in, content_type: content_type, content_length: byte_size, checksum: checksum, custom_metadata: custom_metadata, encryption_key: encryption_key)
75
79
  else
76
80
  super
@@ -78,6 +82,8 @@ module ActiveStorageEncryption
78
82
  end
79
83
 
80
84
  def open(tmpdir: nil, &block)
85
+ ensure_encryption_key_set! if service_encrypted?
86
+
81
87
  service.open(
82
88
  key,
83
89
  encryption_key: encryption_key,
@@ -91,6 +97,8 @@ module ActiveStorageEncryption
91
97
 
92
98
  def service_headers_for_direct_upload
93
99
  if service_encrypted?
100
+ ensure_encryption_key_set!
101
+
94
102
  service.headers_for_direct_upload(key, filename: filename, content_type: content_type, content_length: byte_size, checksum: checksum, custom_metadata: custom_metadata, encryption_key: encryption_key)
95
103
  else
96
104
  super
@@ -99,6 +107,8 @@ module ActiveStorageEncryption
99
107
 
100
108
  def upload_without_unfurling(io)
101
109
  if service_encrypted?
110
+ ensure_encryption_key_set!
111
+
102
112
  service.upload(key, io, checksum: checksum, encryption_key: encryption_key, **service_metadata)
103
113
  else
104
114
  super
@@ -109,6 +119,8 @@ module ActiveStorageEncryption
109
119
  # That'll use a lot of RAM for very large files. If a block is given, then the download is streamed and yielded in chunks.
110
120
  def download(&block)
111
121
  if service_encrypted?
122
+ ensure_encryption_key_set!
123
+
112
124
  service.download(key, encryption_key: encryption_key, &block)
113
125
  else
114
126
  super
@@ -117,23 +129,29 @@ module ActiveStorageEncryption
117
129
 
118
130
  def download_chunk(range)
119
131
  if service_encrypted?
132
+ ensure_encryption_key_set!
133
+
120
134
  service.download_chunk(key, range, encryption_key: encryption_key)
121
135
  else
122
136
  super
123
137
  end
124
138
  end
125
139
 
126
- def compose(keys)
140
+ def compose(keys, source_encryption_keys: [])
127
141
  if service_encrypted?
142
+ ensure_encryption_key_set!
143
+
128
144
  self.composed = true
129
- service.compose(keys, key, encryption_key: encryption_key, **service_metadata)
145
+ service.compose(keys, key, encryption_key: encryption_key, source_encryption_keys: source_encryption_keys, **service_metadata)
130
146
  else
131
- super
147
+ super(keys)
132
148
  end
133
149
  end
134
150
 
135
151
  def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline, filename: nil, **options)
136
152
  if service_encrypted?
153
+ ensure_encryption_key_set!
154
+
137
155
  service.url(
138
156
  key, expires_in: expires_in, filename: ActiveStorage::Filename.wrap(filename || self.filename),
139
157
  encryption_key: encryption_key,
@@ -156,6 +174,12 @@ module ActiveStorageEncryption
156
174
  end
157
175
  super
158
176
  end
177
+
178
+ private
179
+
180
+ def ensure_encryption_key_set!
181
+ raise EncryptionKeyMissingError, "Encryption key must be present" unless encryption_key.present?
182
+ end
159
183
  end
160
184
 
161
185
  module BlobIdentifiableInstanceMethods
@@ -178,6 +202,8 @@ module ActiveStorageEncryption
178
202
 
179
203
  module DownloaderInstanceMethods
180
204
  def open(key, encryption_key: nil, checksum: nil, verify: true, name: "ActiveStorage-", tmpdir: nil, &blk)
205
+ raise EncryptionKeyMissingError, "An encryption key must be supplied when using an encrypted service" if !encryption_key && service.respond_to?(:encrypted?) && service.encrypted?
206
+
181
207
  open_tempfile(name, tmpdir) do |file|
182
208
  download(key, file, encryption_key: encryption_key)
183
209
  verify_integrity_of(file, checksum: checksum) if verify
@@ -189,6 +215,8 @@ module ActiveStorageEncryption
189
215
 
190
216
  def download(key, file, encryption_key: nil)
191
217
  if service.respond_to?(:encrypted?) && service.encrypted?
218
+ raise "An encryption key must be supplied when using an encrypted service" unless encryption_key
219
+
192
220
  file.binmode
193
221
  service.download(key, encryption_key: encryption_key) { |chunk| file.write(chunk) }
194
222
  file.flush
@@ -18,7 +18,7 @@ module ActiveStorageEncryption::PrivateUrlPolicy
18
18
  @private_url_policy
19
19
  end
20
20
 
21
- def private_url_for_streaming_via_controller(key, blob_byte_size:, expires_in:, filename:, content_type:, disposition:, encryption_key:)
21
+ def private_url_for_streaming_via_controller(key, expires_in:, filename:, content_type:, disposition:, encryption_key:, blob_byte_size:)
22
22
  if private_url_policy == :disable
23
23
  raise ActiveStorageEncryption::StreamingDisabled, <<~EOS
24
24
  Requested a signed GET URL for #{key.inspect} on service #{name}. This service
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveStorageEncryption
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -10,6 +10,7 @@ module ActiveStorageEncryption
10
10
  autoload :EncryptedDiskService, __dir__ + "/active_storage_encryption/encrypted_disk_service.rb"
11
11
  autoload :EncryptedMirrorService, __dir__ + "/active_storage_encryption/encrypted_mirror_service.rb"
12
12
  autoload :EncryptedS3Service, __dir__ + "/active_storage_encryption/encrypted_s3_service.rb"
13
+ autoload :EncryptedGCSService, __dir__ + "/active_storage_encryption/encrypted_gcs_service.rb"
13
14
  autoload :Overrides, __dir__ + "/active_storage_encryption/overrides.rb"
14
15
 
15
16
  class IncorrectEncryptionKey < ArgumentError
@@ -0,0 +1,9 @@
1
+ class AddEncryptionKeyToActiveStorageBlobs < ActiveRecord::Migration[7.2]
2
+ def change
3
+ # You _must_ use attribute encryption for this column. Rails uses base64 and JSON encoding
4
+ # for encrypted attributes, so they can be stored as a string. The "raw" encryption key
5
+ # that active_storage_encryption will generate and assign to the Blob is going to be
6
+ # binary, however.
7
+ add_column :active_storage_blobs, :encryption_key, :string, if_not_exists: true
8
+ end
9
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module ActiveStorageEncryption
7
+ # The generator is used to install ActiveStorageEncryption. It adds the `encryption_key`
8
+ # column to ActiveStorage::Blob.
9
+ # Run it with `bin/rails g active_storage_encryption:install` in your console.
10
+ class InstallGenerator < Rails::Generators::Base
11
+ include ActiveRecord::Generators::Migration
12
+
13
+ source_paths << File.join(File.dirname(__FILE__, 2))
14
+
15
+ # Generates monolithic migration file that contains all database changes.
16
+ def create_migration_file
17
+ # Adding a new migration to the gem is then just adding a file.
18
+ migration_file_paths_in_order = Dir.glob(__dir__ + "/*.rb.erb").sort
19
+ migration_file_paths_in_order.each do |migration_template_path|
20
+ untemplated_migration_filename = File.basename(migration_template_path).gsub(/\.erb$/, "")
21
+ migration_template(migration_template_path, File.join(db_migrate_path, untemplated_migration_filename))
22
+ end
23
+ end
24
+ end
25
+ end
File without changes
File without changes
File without changes
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User < ApplicationRecord
4
+ has_one_attached :file, service: :encrypted_disk
5
+ end
@@ -42,6 +42,8 @@ Rails.application.configure do
42
42
  # Tell Active Support which deprecation messages to disallow.
43
43
  config.active_support.disallowed_deprecation_warnings = []
44
44
 
45
+ config.active_storage.service = :test
46
+
45
47
  # Raises error for missing translations.
46
48
  # config.i18n.raise_on_missing_translations = true
47
49
 
@@ -19,3 +19,6 @@ encrypted_mirror:
19
19
  - encrypted_disk
20
20
  mirrors:
21
21
  - test
22
+
23
+ encrypted_gcs_service:
24
+ service: EncryptedGCS
@@ -0,0 +1,7 @@
1
+ class CreateUsers < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :users do |t|
4
+ t.timestamps
5
+ end
6
+ end
7
+ end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  # This file is auto-generated from the current state of the database. Instead
4
2
  # of editing this file, please use the migrations feature of Active Record to
5
3
  # incrementally modify your database, and then regenerate this schema definition.
@@ -12,7 +10,7 @@
12
10
  #
13
11
  # It's strongly recommended that you check this file into your version control system.
14
12
 
15
- ActiveRecord::Schema[7.2].define(version: 2025_03_04_023853) do
13
+ ActiveRecord::Schema[7.2].define(version: 2025_04_28_093315) do
16
14
  create_table "active_storage_attachments", force: :cascade do |t|
17
15
  t.string "name", null: false
18
16
  t.string "record_type", null: false
@@ -42,6 +40,11 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_023853) do
42
40
  t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
43
41
  end
44
42
 
43
+ create_table "users", force: :cascade do |t|
44
+ t.datetime "created_at", null: false
45
+ t.datetime "updated_at", null: false
46
+ end
47
+
45
48
  add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
46
49
  add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
47
50
  end
File without changes
File without changes
File without changes
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class ActiveStorageEncryption::EncryptedGCSServiceTest < ActiveSupport::TestCase
6
+ def config
7
+ {
8
+ project_id: "sandbox-ci-25b8",
9
+ bucket: "sandbox-ci-testing-secure-documents",
10
+ private_url_policy: "stream"
11
+ }
12
+ end
13
+
14
+ setup do
15
+ if ENV["GOOGLE_APPLICATION_CREDENTIALS"].blank?
16
+ skip "You need GOOGLE_APPLICATION_CREDENTIALS set in your env and it needs to point to the JSON keyfile for GCS"
17
+ end
18
+
19
+ @textfile = StringIO.new("Secure document that needs to be stored encrypted.")
20
+ @textfile2 = StringIO.new("While being neatly organized all in a days work aat the job.")
21
+ @service = ActiveStorageEncryption::EncryptedGCSService.new(**config)
22
+ @service.name = "encrypted_gcs_service"
23
+
24
+ @encryption_key = ActiveStorage::Blob.generate_random_encryption_key
25
+ @gcs_key_length_range = (0...ActiveStorageEncryption::EncryptedGCSService::GCS_ENCRYPTION_KEY_LENGTH_BYTES) # 32 bytes
26
+ end
27
+
28
+ def run_id
29
+ # We use a shared GCS bucket, and multiple runs of the test suite may write into it at the same time.
30
+ # To prevent clobbering and conflicts, assign a "test run ID" and mix it into the object keys. Keep that
31
+ # value stable across the test suite.
32
+ @test_suite_run_id ||= SecureRandom.base36(10)
33
+ end
34
+
35
+ def test_encrypted_question_method
36
+ assert @service.encrypted?
37
+ end
38
+
39
+ def test_forbids_private_urls_with_disabled_policy
40
+ @service.private_url_policy = :disable
41
+
42
+ rng = Random.new(Minitest.seed)
43
+ key = "#{run_id}-streamed-key-#{rng.hex(4)}"
44
+ encryption_key = Random.bytes(68)
45
+ plaintext_upload_bytes = rng.bytes(425)
46
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:)
47
+
48
+ # ActiveStorage wraps the passed filename in a wrapper thingy
49
+ filename_with_sanitization = ActiveStorage::Filename.new("temp.bin")
50
+
51
+ assert_raises(ActiveStorageEncryption::StreamingDisabled) do
52
+ @service.url(key, filename: filename_with_sanitization, blob_byte_size: plaintext_upload_bytes.bytesize, content_type: "binary/octet-stream", disposition: "inline", encryption_key:, expires_in: 10.seconds)
53
+ end
54
+ end
55
+
56
+ def test_exists
57
+ rng = Random.new(Minitest.seed)
58
+
59
+ key = "#{run_id}-encrypted-exists-key-#{rng.hex(4)}"
60
+ encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it
61
+ plaintext_upload_bytes = rng.bytes(1024)
62
+
63
+ assert_nothing_raised { @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:) }
64
+ refute @service.exist?(key + "-definitely-not-present")
65
+ assert @service.exist?(key)
66
+ end
67
+
68
+ def test_generates_private_streaming_urls_with_streaming_policy
69
+ @service.private_url_policy = :stream
70
+
71
+ rng = Random.new(Minitest.seed)
72
+ key = "#{run_id}-streamed-key-#{rng.hex(4)}"
73
+ encryption_key = Random.bytes(68)
74
+ plaintext_upload_bytes = rng.bytes(425)
75
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:)
76
+
77
+ # The streaming URL generation uses Rails routing, so it needs
78
+ # ActiveStorage::Current.url_options to be set
79
+ # We need to use a hostname for ActiveStorage which is in the Rails authorized hosts.
80
+ # see https://stackoverflow.com/a/60573259/153886
81
+ ActiveStorage::Current.url_options = {
82
+ host: "www.example.com",
83
+ protocol: "https"
84
+ }
85
+
86
+ # ActiveStorage wraps the passed filename in a wrapper thingy
87
+ filename_with_sanitization = ActiveStorage::Filename.new("temp.bin")
88
+ url = @service.url(key, blob_byte_size: plaintext_upload_bytes.bytesize,
89
+ filename: filename_with_sanitization, content_type: "binary/octet-stream",
90
+ disposition: "inline", encryption_key:, expires_in: 10.seconds)
91
+ assert url.include?("/active-storage-encryption/blob/")
92
+ end
93
+
94
+ def test_generates_private_urls_with_require_headers_policy
95
+ @service.private_url_policy = :require_headers
96
+
97
+ rng = Random.new(Minitest.seed)
98
+ key = "#{run_id}-streamed-key-#{rng.hex(4)}"
99
+ encryption_key = Random.bytes(68)
100
+ plaintext_upload_bytes = rng.bytes(425)
101
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:)
102
+
103
+ # ActiveStorage wraps the passed filename in a wrapper thingy
104
+ filename_with_sanitization = ActiveStorage::Filename.new("temp.bin")
105
+ url = @service.url(key, blob_byte_size: plaintext_upload_bytes.bytesize,
106
+ filename: filename_with_sanitization, content_type: "binary/octet-stream",
107
+ disposition: "inline", encryption_key:, expires_in: 240.seconds)
108
+
109
+ query_params_hash = URI.decode_www_form(URI.parse(url).query).to_h
110
+
111
+ # Downcased header names for this test since that's what we get back from signing process.
112
+ expected_headers = ["x-goog-encryption-algorithm", "x-goog-encryption-key", "x-goog-encryption-key-sha256"]
113
+ signed_headers = query_params_hash["X-Goog-SignedHeaders"].split(";")
114
+ assert expected_headers.all? { |header| header.in?(signed_headers) }
115
+
116
+ uri = URI(url)
117
+ req = Net::HTTP::Get.new(uri)
118
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http|
119
+ http.request(req)
120
+ }
121
+ assert_equal "400", res.code
122
+
123
+ # TODO make this a headers_for_private_download like in the s3 service
124
+ download_headers = {
125
+ "content-type" => "binary/octet-stream",
126
+ "Content-Disposition" => "inline; filename=\"temp.bin\"; filename*=UTF-8''temp.bin",
127
+ "x-goog-encryption-algorithm" => "AES256",
128
+ "x-goog-encryption-key" => Base64.strict_encode64(encryption_key[@gcs_key_length_range]),
129
+ "x-goog-encryption-key-sha256" => Digest::SHA256.base64digest(encryption_key[@gcs_key_length_range])
130
+ }
131
+ download_headers.each_pair { |key, value| req[key] = value }
132
+
133
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http|
134
+ http.request(req)
135
+ }
136
+ assert_equal "200", res.code
137
+ assert_equal plaintext_upload_bytes, res.body
138
+ end
139
+
140
+ def test_basic_gcs_readback
141
+ rng = Random.new(Minitest.seed)
142
+
143
+ key = "#{run_id}-encrypted-key-#{rng.hex(4)}"
144
+ encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it
145
+ plaintext_upload_bytes = rng.bytes(1024)
146
+
147
+ assert_nothing_raised do
148
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:)
149
+ end
150
+ readback = @service.download(key, encryption_key:)
151
+ assert_equal readback, plaintext_upload_bytes
152
+ end
153
+
154
+ def test_accepts_direct_upload_with_signature_and_headers
155
+ rng = Random.new(Minitest.seed)
156
+
157
+ key = "#{run_id}-encrypted-key-direct-upload-#{rng.hex(4)}"
158
+ encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it
159
+ plaintext_upload_bytes = rng.bytes(1024)
160
+
161
+ url = @service.url_for_direct_upload(key,
162
+ encryption_key:,
163
+ expires_in: 5.minutes.to_i,
164
+ content_type: "binary/octet-stream",
165
+ content_length: plaintext_upload_bytes.bytesize,
166
+ checksum: Digest::MD5.base64digest(plaintext_upload_bytes))
167
+
168
+ query_params_hash = URI.decode_www_form(URI.parse(url).query).to_h
169
+
170
+ # Downcased header names for this test since that's what we get back from signing process.
171
+ expected_headers = ["content-md5", "x-goog-encryption-algorithm", "x-goog-encryption-key", "x-goog-encryption-key-sha256"]
172
+ signed_headers = query_params_hash["X-Goog-SignedHeaders"].split(";")
173
+ assert expected_headers.all? { |header| header.in?(signed_headers) }
174
+
175
+ assert_equal "300", query_params_hash["X-Goog-Expires"]
176
+
177
+ should_be_headers = {
178
+ "Content-Type" => "binary/octet-stream",
179
+ "Content-MD5" => Digest::MD5.base64digest(plaintext_upload_bytes),
180
+ "x-goog-encryption-algorithm" => "AES256",
181
+ "x-goog-encryption-key" => Base64.strict_encode64(encryption_key[@gcs_key_length_range]),
182
+ "x-goog-encryption-key-sha256" => Digest::SHA256.base64digest(encryption_key[@gcs_key_length_range])
183
+ }
184
+
185
+ headers = @service.headers_for_direct_upload(key,
186
+ encryption_key:,
187
+ content_type: "binary/octet-stream",
188
+ content_length: plaintext_upload_bytes.bytesize,
189
+ checksum: Digest::MD5.base64digest(plaintext_upload_bytes))
190
+
191
+ assert_equal should_be_headers.sort, headers.sort
192
+
193
+ res = Net::HTTP.put(URI(url), plaintext_upload_bytes, headers)
194
+ assert_equal "200", res.code
195
+
196
+ assert_equal plaintext_upload_bytes, @service.download(key, encryption_key:)
197
+
198
+ @service.delete(key)
199
+ refute @service.exist?(key)
200
+ end
201
+ end
@@ -235,7 +235,10 @@ class ActiveStorageEncryption::EncryptedS3ServiceTest < ActiveSupport::TestCase
235
235
  # Read the objects from something slow, so that threads may switch between one another
236
236
  class SnoozyStringIO < StringIO
237
237
  def read(n = nil, outbuf = nil)
238
- sleep(rand((0.1..0.2)))
238
+ sleep_from = 0.1
239
+ sleep_to = 0.2
240
+ delay_s = rand(sleep_from..sleep_to)
241
+ sleep(delay_s)
239
242
  super
240
243
  end
241
244
  end