active_storage_encryption 0.2.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 850bd7cbc71749f88f1a8e7c4305de38bfbb7c3641ee75864a34ce9f712af65a
4
- data.tar.gz: 2ffa5b8c6abc2038395366138eb6f1d43dd4246f3d728e923a440805b7f53838
3
+ metadata.gz: 6f34184e53ab51b15143acc6fd7bbc2ea0e43a4cc4c0b0d87c065de2631298fb
4
+ data.tar.gz: 8329f26f7141a0762bb0488995bdf219ff01c00d9b374b4cdecc7af895bded11
5
5
  SHA512:
6
- metadata.gz: 489bf8dbd01ee254354cf3dbcbedab9743f9f16f5b93c352cb234c8eef4f909c31916eef7187002eac8b0f66d476fd55d44cf31bd8320ea6fa275b36d62cb3b6
7
- data.tar.gz: 746b6efed5b2e4819f280f511a1eb66882a257b173c6699b85a10606d1eac0210da92f6020c480e08de5e509a34f4eb7ff50f46c703609e5ed284ecd5cb0f23c
6
+ metadata.gz: 30d7ba6406ec77a7521cc47ea24b858e0de4502644516c3ea5a54b025e24ffe305b21f4fcf3244f1361c2fbedcc79f8dc8b8ce6a0e0e3558a202e1e21941a295
7
+ data.tar.gz: 71bf3a1c8ee30daee4bdb43bd8703b2c530e67fb0e68078fc07d208e266d1285c8f390392e6539afaaea839c3fa62e2b2cef46b8a863c4d57409d1ebe399c392
@@ -35,6 +35,7 @@ Gem::Specification.new do |spec|
35
35
  # Testing with cloud services
36
36
  spec.add_development_dependency "aws-sdk-s3"
37
37
  spec.add_development_dependency "net-http"
38
+ spec.add_development_dependency "google-cloud-storage"
38
39
 
39
40
  # Code formatting, linting and testing
40
41
  spec.add_development_dependency "sqlite3"
@@ -42,4 +43,5 @@ Gem::Specification.new do |spec|
42
43
  spec.add_development_dependency "appraisal"
43
44
  spec.add_development_dependency "magic_frozen_string_literal"
44
45
  spec.add_development_dependency "rake"
46
+ spec.add_development_dependency "pry"
45
47
  end
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- active_storage_encryption (0.2.2)
4
+ active_storage_encryption (0.3.0)
5
5
  activestorage
6
6
  block_cipher_kit (>= 0.0.4)
7
7
  rails (>= 7.2.2.1)
@@ -81,6 +81,8 @@ GEM
81
81
  minitest (>= 5.1)
82
82
  securerandom (>= 0.3)
83
83
  tzinfo (~> 2.0, >= 2.0.5)
84
+ addressable (2.8.7)
85
+ public_suffix (>= 2.0.2, < 7.0)
84
86
  appraisal (2.5.0)
85
87
  bundler
86
88
  rake
@@ -108,14 +110,63 @@ GEM
108
110
  bigdecimal (3.1.9)
109
111
  block_cipher_kit (0.0.4)
110
112
  builder (3.3.0)
113
+ coderay (1.1.3)
111
114
  concurrent-ruby (1.3.5)
112
115
  connection_pool (2.5.0)
113
116
  crass (1.0.6)
114
117
  date (3.4.1)
118
+ declarative (0.0.20)
119
+ digest-crc (0.7.0)
120
+ rake (>= 12.0.0, < 14.0.0)
115
121
  drb (2.2.1)
116
122
  erubi (1.13.1)
123
+ faraday (2.13.0)
124
+ faraday-net_http (>= 2.0, < 3.5)
125
+ json
126
+ logger
127
+ faraday-net_http (3.4.0)
128
+ net-http (>= 0.5.0)
117
129
  globalid (1.2.1)
118
130
  activesupport (>= 6.1)
131
+ google-apis-core (0.16.0)
132
+ addressable (~> 2.5, >= 2.5.1)
133
+ googleauth (~> 1.9)
134
+ httpclient (>= 2.8.3, < 3.a)
135
+ mini_mime (~> 1.0)
136
+ mutex_m
137
+ representable (~> 3.0)
138
+ retriable (>= 2.0, < 4.a)
139
+ google-apis-iamcredentials_v1 (0.22.0)
140
+ google-apis-core (>= 0.15.0, < 2.a)
141
+ google-apis-storage_v1 (0.50.0)
142
+ google-apis-core (>= 0.15.0, < 2.a)
143
+ google-cloud-core (1.8.0)
144
+ google-cloud-env (>= 1.0, < 3.a)
145
+ google-cloud-errors (~> 1.0)
146
+ google-cloud-env (2.2.2)
147
+ base64 (~> 0.2)
148
+ faraday (>= 1.0, < 3.a)
149
+ google-cloud-errors (1.5.0)
150
+ google-cloud-storage (1.56.0)
151
+ addressable (~> 2.8)
152
+ digest-crc (~> 0.4)
153
+ google-apis-core (~> 0.13)
154
+ google-apis-iamcredentials_v1 (~> 0.18)
155
+ google-apis-storage_v1 (>= 0.42)
156
+ google-cloud-core (~> 1.6)
157
+ googleauth (~> 1.9)
158
+ mini_mime (~> 1.0)
159
+ google-logging-utils (0.1.0)
160
+ googleauth (1.14.0)
161
+ faraday (>= 1.0, < 3.a)
162
+ google-cloud-env (~> 2.2)
163
+ google-logging-utils (~> 0.1)
164
+ jwt (>= 1.4, < 3.0)
165
+ multi_json (~> 1.11)
166
+ os (>= 0.9, < 2.0)
167
+ signet (>= 0.16, < 2.a)
168
+ httpclient (2.9.0)
169
+ mutex_m
119
170
  i18n (1.14.7)
120
171
  concurrent-ruby (~> 1.0)
121
172
  io-console (0.8.0)
@@ -125,6 +176,8 @@ GEM
125
176
  reline (>= 0.4.2)
126
177
  jmespath (1.6.2)
127
178
  json (2.10.1)
179
+ jwt (2.10.1)
180
+ base64
128
181
  language_server-protocol (3.17.0.4)
129
182
  lint_roller (1.1.0)
130
183
  logger (1.6.6)
@@ -138,8 +191,11 @@ GEM
138
191
  net-pop
139
192
  net-smtp
140
193
  marcel (1.0.4)
194
+ method_source (1.1.0)
141
195
  mini_mime (1.1.5)
142
196
  minitest (5.25.4)
197
+ multi_json (1.15.0)
198
+ mutex_m (0.3.0)
143
199
  net-http (0.6.0)
144
200
  uri
145
201
  net-imap (0.5.6)
@@ -158,6 +214,7 @@ GEM
158
214
  racc (~> 1.4)
159
215
  nokogiri (1.18.3-x86_64-linux-gnu)
160
216
  racc (~> 1.4)
217
+ os (1.1.4)
161
218
  parallel (1.26.3)
162
219
  parser (3.3.7.1)
163
220
  ast (~> 2.4.1)
@@ -165,9 +222,13 @@ GEM
165
222
  pp (0.6.2)
166
223
  prettyprint
167
224
  prettyprint (0.2.0)
225
+ pry (0.15.2)
226
+ coderay (~> 1.1)
227
+ method_source (~> 1.0)
168
228
  psych (5.2.3)
169
229
  date
170
230
  stringio
231
+ public_suffix (6.0.1)
171
232
  racc (1.8.1)
172
233
  rack (3.1.11)
173
234
  rack-session (2.1.0)
@@ -213,6 +274,11 @@ GEM
213
274
  regexp_parser (2.10.0)
214
275
  reline (0.6.0)
215
276
  io-console (~> 0.5)
277
+ representable (3.2.0)
278
+ declarative (< 0.1.0)
279
+ trailblazer-option (>= 0.1.1, < 0.2.0)
280
+ uber (< 0.2.0)
281
+ retriable (3.1.2)
216
282
  rubocop (1.71.2)
217
283
  json (~> 2.3)
218
284
  language_server-protocol (>= 3.17.0)
@@ -232,6 +298,11 @@ GEM
232
298
  securerandom (0.4.1)
233
299
  serve_byte_range (1.0.0)
234
300
  rack (>= 1.0)
301
+ signet (0.19.0)
302
+ addressable (~> 2.8)
303
+ faraday (>= 0.17.5, < 3.a)
304
+ jwt (>= 1.5, < 3.0)
305
+ multi_json (~> 1.10)
235
306
  sqlite3 (2.6.0-arm64-darwin)
236
307
  sqlite3 (2.6.0-x86_64-darwin)
237
308
  sqlite3 (2.6.0-x86_64-linux-gnu)
@@ -250,8 +321,10 @@ GEM
250
321
  stringio (3.1.5)
251
322
  thor (1.3.2)
252
323
  timeout (0.4.3)
324
+ trailblazer-option (0.1.2)
253
325
  tzinfo (2.0.6)
254
326
  concurrent-ruby (~> 1.0)
327
+ uber (0.1.0)
255
328
  unicode-display_width (3.1.4)
256
329
  unicode-emoji (~> 4.0, >= 4.0.4)
257
330
  unicode-emoji (4.0.4)
@@ -265,6 +338,7 @@ GEM
265
338
 
266
339
  PLATFORMS
267
340
  arm64-darwin-21
341
+ arm64-darwin-23
268
342
  arm64-darwin-24
269
343
  x86_64-darwin
270
344
  x86_64-linux
@@ -273,8 +347,10 @@ DEPENDENCIES
273
347
  active_storage_encryption!
274
348
  appraisal
275
349
  aws-sdk-s3
350
+ google-cloud-storage
276
351
  magic_frozen_string_literal
277
352
  net-http
353
+ pry
278
354
  rails (< 8.0)
279
355
  rake
280
356
  sqlite3
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- active_storage_encryption (0.2.2)
4
+ active_storage_encryption (0.3.0)
5
5
  activestorage
6
6
  block_cipher_kit (>= 0.0.4)
7
7
  rails (>= 7.2.2.1)
@@ -81,6 +81,8 @@ GEM
81
81
  securerandom (>= 0.3)
82
82
  tzinfo (~> 2.0, >= 2.0.5)
83
83
  uri (>= 0.13.1)
84
+ addressable (2.8.7)
85
+ public_suffix (>= 2.0.2, < 7.0)
84
86
  appraisal (2.5.0)
85
87
  bundler
86
88
  rake
@@ -108,14 +110,63 @@ GEM
108
110
  bigdecimal (3.1.9)
109
111
  block_cipher_kit (0.0.4)
110
112
  builder (3.3.0)
113
+ coderay (1.1.3)
111
114
  concurrent-ruby (1.3.5)
112
115
  connection_pool (2.5.0)
113
116
  crass (1.0.6)
114
117
  date (3.4.1)
118
+ declarative (0.0.20)
119
+ digest-crc (0.7.0)
120
+ rake (>= 12.0.0, < 14.0.0)
115
121
  drb (2.2.1)
116
122
  erubi (1.13.1)
123
+ faraday (2.13.0)
124
+ faraday-net_http (>= 2.0, < 3.5)
125
+ json
126
+ logger
127
+ faraday-net_http (3.4.0)
128
+ net-http (>= 0.5.0)
117
129
  globalid (1.2.1)
118
130
  activesupport (>= 6.1)
131
+ google-apis-core (0.16.0)
132
+ addressable (~> 2.5, >= 2.5.1)
133
+ googleauth (~> 1.9)
134
+ httpclient (>= 2.8.3, < 3.a)
135
+ mini_mime (~> 1.0)
136
+ mutex_m
137
+ representable (~> 3.0)
138
+ retriable (>= 2.0, < 4.a)
139
+ google-apis-iamcredentials_v1 (0.22.0)
140
+ google-apis-core (>= 0.15.0, < 2.a)
141
+ google-apis-storage_v1 (0.50.0)
142
+ google-apis-core (>= 0.15.0, < 2.a)
143
+ google-cloud-core (1.8.0)
144
+ google-cloud-env (>= 1.0, < 3.a)
145
+ google-cloud-errors (~> 1.0)
146
+ google-cloud-env (2.2.2)
147
+ base64 (~> 0.2)
148
+ faraday (>= 1.0, < 3.a)
149
+ google-cloud-errors (1.5.0)
150
+ google-cloud-storage (1.56.0)
151
+ addressable (~> 2.8)
152
+ digest-crc (~> 0.4)
153
+ google-apis-core (~> 0.13)
154
+ google-apis-iamcredentials_v1 (~> 0.18)
155
+ google-apis-storage_v1 (>= 0.42)
156
+ google-cloud-core (~> 1.6)
157
+ googleauth (~> 1.9)
158
+ mini_mime (~> 1.0)
159
+ google-logging-utils (0.1.0)
160
+ googleauth (1.14.0)
161
+ faraday (>= 1.0, < 3.a)
162
+ google-cloud-env (~> 2.2)
163
+ google-logging-utils (~> 0.1)
164
+ jwt (>= 1.4, < 3.0)
165
+ multi_json (~> 1.11)
166
+ os (>= 0.9, < 2.0)
167
+ signet (>= 0.16, < 2.a)
168
+ httpclient (2.9.0)
169
+ mutex_m
119
170
  i18n (1.14.7)
120
171
  concurrent-ruby (~> 1.0)
121
172
  io-console (0.8.0)
@@ -125,6 +176,8 @@ GEM
125
176
  reline (>= 0.4.2)
126
177
  jmespath (1.6.2)
127
178
  json (2.10.1)
179
+ jwt (2.10.1)
180
+ base64
128
181
  language_server-protocol (3.17.0.4)
129
182
  lint_roller (1.1.0)
130
183
  logger (1.6.6)
@@ -138,8 +191,11 @@ GEM
138
191
  net-pop
139
192
  net-smtp
140
193
  marcel (1.0.4)
194
+ method_source (1.1.0)
141
195
  mini_mime (1.1.5)
142
196
  minitest (5.25.4)
197
+ multi_json (1.15.0)
198
+ mutex_m (0.3.0)
143
199
  net-http (0.6.0)
144
200
  uri
145
201
  net-imap (0.5.6)
@@ -158,6 +214,7 @@ GEM
158
214
  racc (~> 1.4)
159
215
  nokogiri (1.18.3-x86_64-linux-gnu)
160
216
  racc (~> 1.4)
217
+ os (1.1.4)
161
218
  parallel (1.26.3)
162
219
  parser (3.3.7.1)
163
220
  ast (~> 2.4.1)
@@ -165,9 +222,13 @@ GEM
165
222
  pp (0.6.2)
166
223
  prettyprint
167
224
  prettyprint (0.2.0)
225
+ pry (0.15.2)
226
+ coderay (~> 1.1)
227
+ method_source (~> 1.0)
168
228
  psych (5.2.3)
169
229
  date
170
230
  stringio
231
+ public_suffix (6.0.1)
171
232
  racc (1.8.1)
172
233
  rack (3.1.11)
173
234
  rack-session (2.1.0)
@@ -213,6 +274,11 @@ GEM
213
274
  regexp_parser (2.10.0)
214
275
  reline (0.6.0)
215
276
  io-console (~> 0.5)
277
+ representable (3.2.0)
278
+ declarative (< 0.1.0)
279
+ trailblazer-option (>= 0.1.1, < 0.2.0)
280
+ uber (< 0.2.0)
281
+ retriable (3.1.2)
216
282
  rubocop (1.71.2)
217
283
  json (~> 2.3)
218
284
  language_server-protocol (>= 3.17.0)
@@ -232,6 +298,11 @@ GEM
232
298
  securerandom (0.4.1)
233
299
  serve_byte_range (1.0.0)
234
300
  rack (>= 1.0)
301
+ signet (0.19.0)
302
+ addressable (~> 2.8)
303
+ faraday (>= 0.17.5, < 3.a)
304
+ jwt (>= 1.5, < 3.0)
305
+ multi_json (~> 1.10)
235
306
  sqlite3 (2.6.0-arm64-darwin)
236
307
  sqlite3 (2.6.0-x86_64-darwin)
237
308
  sqlite3 (2.6.0-x86_64-linux-gnu)
@@ -250,8 +321,10 @@ GEM
250
321
  stringio (3.1.5)
251
322
  thor (1.3.2)
252
323
  timeout (0.4.3)
324
+ trailblazer-option (0.1.2)
253
325
  tzinfo (2.0.6)
254
326
  concurrent-ruby (~> 1.0)
327
+ uber (0.1.0)
255
328
  unicode-display_width (3.1.4)
256
329
  unicode-emoji (~> 4.0, >= 4.0.4)
257
330
  unicode-emoji (4.0.4)
@@ -265,6 +338,7 @@ GEM
265
338
 
266
339
  PLATFORMS
267
340
  arm64-darwin-21
341
+ arm64-darwin-23
268
342
  arm64-darwin-24
269
343
  x86_64-darwin
270
344
  x86_64-linux
@@ -273,8 +347,10 @@ DEPENDENCIES
273
347
  active_storage_encryption!
274
348
  appraisal
275
349
  aws-sdk-s3
350
+ google-cloud-storage
276
351
  magic_frozen_string_literal
277
352
  net-http
353
+ pry
278
354
  rails (>= 8.0)
279
355
  rake
280
356
  sqlite3
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Needed so that Rails can find our service definition. It will perform the following
4
+ # steps. Given an "EncryptedDisk" value of the `service:` key in the YAML, it will:
5
+ #
6
+ # * Force-require a file at "active_storage/service/encrypted_disk", from any path on the $LOAD_PATH
7
+ # * Instantiate a class called "ActiveStorage::Service::EncryptedDiskService"
8
+ require_relative "../../active_storage_encryption"
9
+ class ActiveStorage::Service::EncryptedGCSService < ActiveStorageEncryption::EncryptedGCSService
10
+ end
@@ -105,7 +105,7 @@ class ActiveStorageEncryption::EncryptedBlobProxyController < ActionController::
105
105
  blob_etag = key.inspect # Strong ETags must be quoted
106
106
  status, headers, ranges_body = ServeByteRange.serve_ranges(request.env,
107
107
  resource_size: blob_byte_size,
108
- etag: blob_etag, # TODO
108
+ etag: blob_etag,
109
109
  resource_content_type: type,
110
110
  &streaming_proc)
111
111
 
@@ -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
@@ -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.2"
4
+ VERSION = "0.3.0"
5
5
  end