active_storage_encryption 0.1.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 (78) hide show
  1. checksums.yaml +7 -0
  2. data/Appraisals +7 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +236 -0
  5. data/Rakefile +17 -0
  6. data/bin/rails +26 -0
  7. data/bin/rubocop +8 -0
  8. data/config/initializers/active_storage_encryption.rb +9 -0
  9. data/config/routes.rb +7 -0
  10. data/gemfiles/rails_7.gemfile +7 -0
  11. data/gemfiles/rails_7.gemfile.lock +276 -0
  12. data/gemfiles/rails_8.gemfile +7 -0
  13. data/gemfiles/rails_8.gemfile.lock +276 -0
  14. data/lib/active_storage/service/encrypted_disk_service.rb +10 -0
  15. data/lib/active_storage/service/encrypted_mirror_service.rb +10 -0
  16. data/lib/active_storage/service/encrypted_s3_service.rb +10 -0
  17. data/lib/active_storage_encryption/encrypted_blobs_controller.rb +163 -0
  18. data/lib/active_storage_encryption/encrypted_disk_service/v1_scheme.rb +28 -0
  19. data/lib/active_storage_encryption/encrypted_disk_service/v2_scheme.rb +51 -0
  20. data/lib/active_storage_encryption/encrypted_disk_service.rb +186 -0
  21. data/lib/active_storage_encryption/encrypted_mirror_service.rb +76 -0
  22. data/lib/active_storage_encryption/encrypted_s3_service.rb +236 -0
  23. data/lib/active_storage_encryption/engine.rb +7 -0
  24. data/lib/active_storage_encryption/overrides.rb +201 -0
  25. data/lib/active_storage_encryption/private_url_policy.rb +53 -0
  26. data/lib/active_storage_encryption/resumable_gcs_upload.rb +194 -0
  27. data/lib/active_storage_encryption/version.rb +5 -0
  28. data/lib/active_storage_encryption.rb +79 -0
  29. data/lib/tasks/active_storage_encryption_tasks.rake +6 -0
  30. data/test/active_storage_encryption_test.rb +9 -0
  31. data/test/dummy/Rakefile +8 -0
  32. data/test/dummy/app/assets/stylesheets/application.css +1 -0
  33. data/test/dummy/app/controllers/application_controller.rb +6 -0
  34. data/test/dummy/app/helpers/application_helper.rb +4 -0
  35. data/test/dummy/app/models/application_record.rb +5 -0
  36. data/test/dummy/app/views/layouts/application.html.erb +22 -0
  37. data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
  38. data/test/dummy/app/views/pwa/service-worker.js +26 -0
  39. data/test/dummy/bin/rails +4 -0
  40. data/test/dummy/bin/rake +4 -0
  41. data/test/dummy/bin/setup +37 -0
  42. data/test/dummy/config/application.rb +43 -0
  43. data/test/dummy/config/boot.rb +7 -0
  44. data/test/dummy/config/credentials.yml.enc +1 -0
  45. data/test/dummy/config/database.yml +32 -0
  46. data/test/dummy/config/environment.rb +7 -0
  47. data/test/dummy/config/environments/development.rb +59 -0
  48. data/test/dummy/config/environments/production.rb +81 -0
  49. data/test/dummy/config/environments/test.rb +53 -0
  50. data/test/dummy/config/initializers/content_security_policy.rb +27 -0
  51. data/test/dummy/config/initializers/filter_parameter_logging.rb +10 -0
  52. data/test/dummy/config/initializers/inflections.rb +18 -0
  53. data/test/dummy/config/initializers/permissions_policy.rb +15 -0
  54. data/test/dummy/config/locales/en.yml +31 -0
  55. data/test/dummy/config/master.key +1 -0
  56. data/test/dummy/config/puma.rb +36 -0
  57. data/test/dummy/config/routes.rb +5 -0
  58. data/test/dummy/config/storage.yml +21 -0
  59. data/test/dummy/config.ru +8 -0
  60. data/test/dummy/db/migrate/20250304023851_create_active_storage_tables.active_storage.rb +60 -0
  61. data/test/dummy/db/migrate/20250304023853_add_blob_encryption_key_column.rb +7 -0
  62. data/test/dummy/db/schema.rb +47 -0
  63. data/test/dummy/log/test.log +1022 -0
  64. data/test/dummy/public/404.html +67 -0
  65. data/test/dummy/public/406-unsupported-browser.html +66 -0
  66. data/test/dummy/public/422.html +67 -0
  67. data/test/dummy/public/500.html +66 -0
  68. data/test/dummy/public/icon.png +0 -0
  69. data/test/dummy/public/icon.svg +3 -0
  70. data/test/dummy/storage/test.sqlite3 +0 -0
  71. data/test/dummy/storage/x6/pl/x6plznfuhrsyjn9pox2a6xgmcs3x +0 -0
  72. data/test/dummy/storage/yq/sv/yqsvw5a72b3fv719zq8a6yb7lv0j +0 -0
  73. data/test/integration/encrypted_blobs_controller_test.rb +400 -0
  74. data/test/lib/encrypted_disk_service_test.rb +387 -0
  75. data/test/lib/encrypted_mirror_service_test.rb +159 -0
  76. data/test/lib/encrypted_s3_service_test.rb +293 -0
  77. data/test/test_helper.rb +19 -0
  78. metadata +264 -0
@@ -0,0 +1,67 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>The page you were looking for doesn't exist (404)</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <style>
7
+ .rails-default-error-page {
8
+ background-color: #EFEFEF;
9
+ color: #2E2F30;
10
+ text-align: center;
11
+ font-family: arial, sans-serif;
12
+ margin: 0;
13
+ }
14
+
15
+ .rails-default-error-page div.dialog {
16
+ width: 95%;
17
+ max-width: 33em;
18
+ margin: 4em auto 0;
19
+ }
20
+
21
+ .rails-default-error-page div.dialog > div {
22
+ border: 1px solid #CCC;
23
+ border-right-color: #999;
24
+ border-left-color: #999;
25
+ border-bottom-color: #BBB;
26
+ border-top: #B00100 solid 4px;
27
+ border-top-left-radius: 9px;
28
+ border-top-right-radius: 9px;
29
+ background-color: white;
30
+ padding: 7px 12% 0;
31
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
32
+ }
33
+
34
+ .rails-default-error-page h1 {
35
+ font-size: 100%;
36
+ color: #730E15;
37
+ line-height: 1.5em;
38
+ }
39
+
40
+ .rails-default-error-page div.dialog > p {
41
+ margin: 0 0 1em;
42
+ padding: 1em;
43
+ background-color: #F7F7F7;
44
+ border: 1px solid #CCC;
45
+ border-right-color: #999;
46
+ border-left-color: #999;
47
+ border-bottom-color: #999;
48
+ border-bottom-left-radius: 4px;
49
+ border-bottom-right-radius: 4px;
50
+ border-top-color: #DADADA;
51
+ color: #666;
52
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
53
+ }
54
+ </style>
55
+ </head>
56
+
57
+ <body class="rails-default-error-page">
58
+ <!-- This file lives in public/404.html -->
59
+ <div class="dialog">
60
+ <div>
61
+ <h1>The page you were looking for doesn't exist.</h1>
62
+ <p>You may have mistyped the address or the page may have moved.</p>
63
+ </div>
64
+ <p>If you are the application owner check the logs for more information.</p>
65
+ </div>
66
+ </body>
67
+ </html>
@@ -0,0 +1,66 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Your browser is not supported (406)</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <style>
7
+ .rails-default-error-page {
8
+ background-color: #EFEFEF;
9
+ color: #2E2F30;
10
+ text-align: center;
11
+ font-family: arial, sans-serif;
12
+ margin: 0;
13
+ }
14
+
15
+ .rails-default-error-page div.dialog {
16
+ width: 95%;
17
+ max-width: 33em;
18
+ margin: 4em auto 0;
19
+ }
20
+
21
+ .rails-default-error-page div.dialog > div {
22
+ border: 1px solid #CCC;
23
+ border-right-color: #999;
24
+ border-left-color: #999;
25
+ border-bottom-color: #BBB;
26
+ border-top: #B00100 solid 4px;
27
+ border-top-left-radius: 9px;
28
+ border-top-right-radius: 9px;
29
+ background-color: white;
30
+ padding: 7px 12% 0;
31
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
32
+ }
33
+
34
+ .rails-default-error-page h1 {
35
+ font-size: 100%;
36
+ color: #730E15;
37
+ line-height: 1.5em;
38
+ }
39
+
40
+ .rails-default-error-page div.dialog > p {
41
+ margin: 0 0 1em;
42
+ padding: 1em;
43
+ background-color: #F7F7F7;
44
+ border: 1px solid #CCC;
45
+ border-right-color: #999;
46
+ border-left-color: #999;
47
+ border-bottom-color: #999;
48
+ border-bottom-left-radius: 4px;
49
+ border-bottom-right-radius: 4px;
50
+ border-top-color: #DADADA;
51
+ color: #666;
52
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
53
+ }
54
+ </style>
55
+ </head>
56
+
57
+ <body class="rails-default-error-page">
58
+ <!-- This file lives in public/406-unsupported-browser.html -->
59
+ <div class="dialog">
60
+ <div>
61
+ <h1>Your browser is not supported.</h1>
62
+ <p>Please upgrade your browser to continue.</p>
63
+ </div>
64
+ </div>
65
+ </body>
66
+ </html>
@@ -0,0 +1,67 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>The change you wanted was rejected (422)</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <style>
7
+ .rails-default-error-page {
8
+ background-color: #EFEFEF;
9
+ color: #2E2F30;
10
+ text-align: center;
11
+ font-family: arial, sans-serif;
12
+ margin: 0;
13
+ }
14
+
15
+ .rails-default-error-page div.dialog {
16
+ width: 95%;
17
+ max-width: 33em;
18
+ margin: 4em auto 0;
19
+ }
20
+
21
+ .rails-default-error-page div.dialog > div {
22
+ border: 1px solid #CCC;
23
+ border-right-color: #999;
24
+ border-left-color: #999;
25
+ border-bottom-color: #BBB;
26
+ border-top: #B00100 solid 4px;
27
+ border-top-left-radius: 9px;
28
+ border-top-right-radius: 9px;
29
+ background-color: white;
30
+ padding: 7px 12% 0;
31
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
32
+ }
33
+
34
+ .rails-default-error-page h1 {
35
+ font-size: 100%;
36
+ color: #730E15;
37
+ line-height: 1.5em;
38
+ }
39
+
40
+ .rails-default-error-page div.dialog > p {
41
+ margin: 0 0 1em;
42
+ padding: 1em;
43
+ background-color: #F7F7F7;
44
+ border: 1px solid #CCC;
45
+ border-right-color: #999;
46
+ border-left-color: #999;
47
+ border-bottom-color: #999;
48
+ border-bottom-left-radius: 4px;
49
+ border-bottom-right-radius: 4px;
50
+ border-top-color: #DADADA;
51
+ color: #666;
52
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
53
+ }
54
+ </style>
55
+ </head>
56
+
57
+ <body class="rails-default-error-page">
58
+ <!-- This file lives in public/422.html -->
59
+ <div class="dialog">
60
+ <div>
61
+ <h1>The change you wanted was rejected.</h1>
62
+ <p>Maybe you tried to change something you didn't have access to.</p>
63
+ </div>
64
+ <p>If you are the application owner check the logs for more information.</p>
65
+ </div>
66
+ </body>
67
+ </html>
@@ -0,0 +1,66 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>We're sorry, but something went wrong (500)</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <style>
7
+ .rails-default-error-page {
8
+ background-color: #EFEFEF;
9
+ color: #2E2F30;
10
+ text-align: center;
11
+ font-family: arial, sans-serif;
12
+ margin: 0;
13
+ }
14
+
15
+ .rails-default-error-page div.dialog {
16
+ width: 95%;
17
+ max-width: 33em;
18
+ margin: 4em auto 0;
19
+ }
20
+
21
+ .rails-default-error-page div.dialog > div {
22
+ border: 1px solid #CCC;
23
+ border-right-color: #999;
24
+ border-left-color: #999;
25
+ border-bottom-color: #BBB;
26
+ border-top: #B00100 solid 4px;
27
+ border-top-left-radius: 9px;
28
+ border-top-right-radius: 9px;
29
+ background-color: white;
30
+ padding: 7px 12% 0;
31
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
32
+ }
33
+
34
+ .rails-default-error-page h1 {
35
+ font-size: 100%;
36
+ color: #730E15;
37
+ line-height: 1.5em;
38
+ }
39
+
40
+ .rails-default-error-page div.dialog > p {
41
+ margin: 0 0 1em;
42
+ padding: 1em;
43
+ background-color: #F7F7F7;
44
+ border: 1px solid #CCC;
45
+ border-right-color: #999;
46
+ border-left-color: #999;
47
+ border-bottom-color: #999;
48
+ border-bottom-left-radius: 4px;
49
+ border-bottom-right-radius: 4px;
50
+ border-top-color: #DADADA;
51
+ color: #666;
52
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
53
+ }
54
+ </style>
55
+ </head>
56
+
57
+ <body class="rails-default-error-page">
58
+ <!-- This file lives in public/500.html -->
59
+ <div class="dialog">
60
+ <div>
61
+ <h1>We're sorry, but something went wrong.</h1>
62
+ </div>
63
+ <p>If you are the application owner check the logs for more information.</p>
64
+ </div>
65
+ </body>
66
+ </html>
Binary file
@@ -0,0 +1,3 @@
1
+ <svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
2
+ <rect width="100%" height="100%" fill="red"/>
3
+ </svg>
Binary file
@@ -0,0 +1,400 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class ActiveStorageEncryptionEncryptedBlobsControllerTest < ActionDispatch::IntegrationTest
6
+ setup do
7
+ @storage_dir = Dir.mktmpdir
8
+ @other_storage_dir = Dir.mktmpdir
9
+ @service = ActiveStorageEncryption::EncryptedDiskService.new(root: @storage_dir, private_url_policy: "stream")
10
+ @service.name = "amazing_encrypting_disk_service" # Needed for the controller and service lookup
11
+
12
+ # Hack: sneakily add our service to them configurations
13
+ # ActiveStorage::Blob.services.send(:services)["amazing_encrypting_disk_service"] = @service
14
+
15
+ # We need to set our service as the default, because the controller does lookup from the application config -
16
+ # which does not include the service we define here
17
+ @previous_default_service = ActiveStorage::Blob.service
18
+ @previous_services = ActiveStorage::Blob.services
19
+
20
+ # To catch potential issues where something goes to the default service by mistake, let's set a
21
+ # different Service as the default
22
+ @non_encrypted_default_service = ActiveStorage::Service::DiskService.new(root: @other_storage_dir)
23
+ ActiveStorage::Blob.service = @non_encrypted_default_service
24
+ ActiveStorage::Blob.services = {@service.name => @service} # That too
25
+
26
+ # This needs to be set
27
+ ActiveStorageEncryption::Engine.routes.default_url_options = {host: "www.example.com"}
28
+
29
+ # We need to use a hostname for ActiveStorage which is in the Rails authorized hosts.
30
+ # see https://stackoverflow.com/a/60573259/153886
31
+ ActiveStorage::Current.url_options = {
32
+ host: "www.example.com",
33
+ protocol: "https"
34
+ }
35
+ freeze_time # For testing expiring tokens
36
+ https! # So that all requests are simulated as SSL
37
+ end
38
+
39
+ def teardown
40
+ unfreeze_time
41
+ ActiveStorage::Blob.service = @previous_default_service
42
+ ActiveStorage::Blob.services = @previous_services
43
+ FileUtils.rm_rf(@storage_dir)
44
+ FileUtils.rm_rf(@other_storage_dir)
45
+ end
46
+
47
+ def engine_routes
48
+ ActiveStorageEncryption::Engine.routes.url_helpers
49
+ end
50
+
51
+ test "show() returns the decrypted blob body" do
52
+ rng = Random.new(Minitest.seed)
53
+
54
+ key = SecureRandom.base36(12)
55
+ encryption_key = rng.bytes(32)
56
+ plaintext = rng.bytes(512)
57
+ @service.upload(key, StringIO.new(plaintext).binmode, encryption_key: encryption_key)
58
+
59
+ streaming_url = @service.url(key, encryption_key: encryption_key, filename: ActiveStorage::Filename.new("private.doc"), expires_in: 30.seconds, disposition: "inline", content_type: "x-office/severance")
60
+ get streaming_url
61
+
62
+ assert_response :success
63
+ assert_equal "x-office/severance", response.headers["content-type"]
64
+ assert_equal plaintext, response.body
65
+ end
66
+
67
+ test "show() refuses a request which goes to a non-encrypted Service" do
68
+ rng = Random.new(Minitest.seed)
69
+
70
+ key = SecureRandom.base36(12)
71
+ encryption_key = rng.bytes(32)
72
+ plaintext = rng.bytes(512)
73
+ @service.upload(key, StringIO.new(plaintext).binmode, encryption_key: encryption_key)
74
+
75
+ streaming_url = @service.url(key, encryption_key: encryption_key, filename: ActiveStorage::Filename.new("private.doc"), expires_in: 30.seconds, disposition: "inline", content_type: "x-office/severance")
76
+
77
+ # Sneak in a non-encrypted service under the same key
78
+ ActiveStorage::Blob.services[@service.name] = @non_encrypted_default_service
79
+
80
+ get streaming_url
81
+ assert_response :forbidden
82
+ end
83
+
84
+ test "show() refuses a request which has an incorrect encryption key" do
85
+ rng = Random.new(Minitest.seed)
86
+
87
+ key = SecureRandom.base36(12)
88
+ encryption_key = rng.bytes(32)
89
+ plaintext = rng.bytes(512)
90
+ @service.upload(key, StringIO.new(plaintext).binmode, encryption_key: encryption_key)
91
+
92
+ another_encryption_key = rng.bytes(32)
93
+ refute_equal encryption_key, another_encryption_key
94
+
95
+ streaming_url = @service.url(key, encryption_key: another_encryption_key, filename: ActiveStorage::Filename.new("private.doc"), expires_in: 30.seconds, disposition: "inline", content_type: "x-office/severance")
96
+ get streaming_url
97
+
98
+ assert_response :forbidden
99
+ end
100
+
101
+ test "show() refuses a request with a garbage token" do
102
+ get engine_routes.encrypted_blob_streaming_get_path(token: "garbage", filename: "exfil.bin")
103
+ assert_response :forbidden
104
+ end
105
+
106
+ test "show() refuses a request with a token that has been encrypted using an incorrect encryption key" do
107
+ https!
108
+ rng = Random.new(Minitest.seed)
109
+ encryptor_key = rng.bytes(32)
110
+ other_encryptor = ActiveStorageEncryption::TokenEncryptor.new(encryptor_key, url_safe: encryptor_key)
111
+
112
+ key = SecureRandom.base36(12)
113
+ encryption_key = rng.bytes(32)
114
+ @service.upload(key, StringIO.new(rng.bytes(512)).binmode, encryption_key: encryption_key)
115
+
116
+ streaming_url = ActiveStorageEncryption.stub(:token_encryptor, -> { other_encryptor }) do
117
+ @service.url(key, encryption_key: encryption_key, filename: ActiveStorage::Filename.new("private.doc"), expires_in: 3.seconds, disposition: "inline", content_type: "binary/octet-stream")
118
+ end
119
+
120
+ get streaming_url
121
+ assert_response :forbidden
122
+ end
123
+
124
+ test "show() refuses a request with a token that has expired" do
125
+ rng = Random.new(Minitest.seed)
126
+
127
+ key = SecureRandom.base36(12)
128
+ encryption_key = rng.bytes(32)
129
+ @service.upload(key, StringIO.new(rng.bytes(512)).binmode, encryption_key: encryption_key)
130
+
131
+ streaming_url = @service.url(key, encryption_key: encryption_key, filename: ActiveStorage::Filename.new("private.doc"), expires_in: 3.seconds, disposition: "inline", content_type: "binary/octet-stream")
132
+ travel 5.seconds
133
+
134
+ get streaming_url
135
+ assert_response :forbidden
136
+ end
137
+
138
+ test "show() requires headers if the private_url_policy of the service is set to :require_headers" do
139
+ rng = Random.new(Minitest.seed)
140
+
141
+ key = SecureRandom.base36(12)
142
+ encryption_key = rng.bytes(32)
143
+ plaintext = rng.bytes(512)
144
+ @service.upload(key, StringIO.new(plaintext).binmode, encryption_key: encryption_key)
145
+
146
+ # The policy needs to be set before we generate the token (the token includes require_headers)
147
+ @service.private_url_policy = :require_headers
148
+ streaming_url = @service.url(key, encryption_key: encryption_key, filename: ActiveStorage::Filename.new("private.doc"), expires_in: 30.seconds, disposition: "inline", content_type: "x-office/severance")
149
+
150
+ get streaming_url
151
+ assert_response :forbidden # Without headers
152
+
153
+ get streaming_url, headers: {"HTTP_X_ACTIVE_STORAGE_ENCRYPTION_KEY" => Base64.strict_encode64(encryption_key)}
154
+ assert_response :success
155
+ assert_equal "x-office/severance", response.headers["content-type"]
156
+ assert_equal plaintext, response.body
157
+ end
158
+
159
+ test "show() refuses a request if the service no longer permits private URLs" do
160
+ rng = Random.new(Minitest.seed)
161
+
162
+ key = SecureRandom.base36(12)
163
+ encryption_key = rng.bytes(32)
164
+ plaintext = rng.bytes(512)
165
+ @service.upload(key, StringIO.new(plaintext).binmode, encryption_key: encryption_key)
166
+
167
+ streaming_url = @service.url(key, encryption_key: encryption_key, filename: ActiveStorage::Filename.new("private.doc"), expires_in: 30.seconds, disposition: "inline", content_type: "x-office/severance")
168
+
169
+ @service.private_url_policy = :disable
170
+
171
+ get streaming_url
172
+ assert_response :forbidden # Without headers
173
+
174
+ get streaming_url, headers: {"HTTP_X_ACTIVE_STORAGE_ENCRYPTION_KEY" => Base64.strict_encode64(encryption_key)}
175
+ assert_response :forbidden # Without headers
176
+ end
177
+
178
+ test "create_direct_upload creates a blob and returns the headers and the URL to start the upload, which are for the correct service name" do
179
+ rng = Random.new(Minitest.seed)
180
+ plaintext = rng.bytes(512)
181
+
182
+ params = {
183
+ service_name: @service.name,
184
+ blob: {
185
+ content_type: "x-binary/sensitive",
186
+ filename: "biometrics.sec",
187
+ checksum: Digest::MD5.base64digest(plaintext),
188
+ service_name: @service.name,
189
+ byte_size: plaintext.bytesize,
190
+ metadata: {womp: 1}
191
+ }
192
+ }
193
+
194
+ post engine_routes.create_encrypted_blob_direct_upload_url, params: params
195
+
196
+ assert_response :success
197
+
198
+ body_payload = JSON.parse(response.body, symbolize_names: true)
199
+
200
+ assert_equal "amazing_encrypting_disk_service", body_payload[:service_name]
201
+ assert_equal "biometrics.sec", body_payload[:filename]
202
+ assert_equal({womp: "1"}, body_payload[:metadata])
203
+ assert_equal Digest::MD5.base64digest(plaintext), body_payload[:checksum]
204
+ assert_kind_of String, body_payload[:direct_upload][:url]
205
+ assert_kind_of Hash, body_payload[:direct_upload][:headers]
206
+ assert_kind_of String, body_payload[:direct_upload][:headers][:"x-active-storage-encryption-key"]
207
+
208
+ blob = ActiveStorage::Blob.find_signed!(body_payload[:signed_id])
209
+ assert blob.encryption_key
210
+ assert_equal blob.service, @service
211
+ end
212
+
213
+ test "create_direct_upload creates a blob which can then be uploaded via PUT" do
214
+ rng = Random.new(Minitest.seed)
215
+ plaintext = rng.bytes(512)
216
+
217
+ params = {
218
+ service_name: @service.name,
219
+ blob: {
220
+ content_type: "x-binary/sensitive",
221
+ filename: "biometrics.sec",
222
+ checksum: Digest::MD5.base64digest(plaintext),
223
+ service_name: @service.name,
224
+ byte_size: plaintext.bytesize
225
+ }
226
+ }
227
+
228
+ post engine_routes.create_encrypted_blob_direct_upload_url, params: params
229
+ assert_response :success
230
+
231
+ body_payload = JSON.parse(response.body, symbolize_names: true)
232
+ url_to_put_to = body_payload[:direct_upload][:url]
233
+ headers = body_payload[:direct_upload][:headers]
234
+ put url_to_put_to, headers: headers, params: plaintext
235
+ assert_response :no_content
236
+
237
+ blob = ActiveStorage::Blob.find_signed!(body_payload[:signed_id])
238
+ readback = blob.download
239
+ assert_equal plaintext, readback
240
+ end
241
+
242
+ test "create_direct_upload refuses without being given an MD5 checksum" do
243
+ rng = Random.new(Minitest.seed)
244
+ plaintext = rng.bytes(512)
245
+
246
+ params = {
247
+ service_name: @service.name,
248
+ blob: {
249
+ content_type: "x-binary/sensitive",
250
+ filename: "biometrics.sec",
251
+ service_name: @service.name,
252
+ byte_size: plaintext.bytesize
253
+ }
254
+ }
255
+
256
+ post engine_routes.create_encrypted_blob_direct_upload_url, params: params
257
+ assert_response :unprocessable_entity
258
+ end
259
+
260
+ test "update() uploads the blob binary data to an encrypted Service using HTTP PUT" do
261
+ rng = Random.new(Minitest.seed)
262
+ plaintext = rng.bytes(512)
263
+ b64_md5 = Digest::MD5.base64digest(plaintext)
264
+
265
+ key = rng.hex(12)
266
+ encryption_key = rng.bytes(65)
267
+
268
+ headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
269
+ destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key)
270
+
271
+ put destination_url, headers: headers, params: plaintext
272
+ assert_response :no_content
273
+
274
+ assert @service.exist?(key)
275
+ readback = @service.download(key, encryption_key: encryption_key)
276
+ assert_equal plaintext, readback
277
+ end
278
+
279
+ test "update() refuses to upload if no Content-MD5 is sent with the request" do
280
+ rng = Random.new(Minitest.seed)
281
+ plaintext = rng.bytes(512)
282
+ b64_md5 = Digest::MD5.base64digest(plaintext)
283
+
284
+ key = rng.hex(12)
285
+ encryption_key = rng.bytes(65)
286
+
287
+ headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
288
+ destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key)
289
+
290
+ headers.delete_if { |k, _| k.downcase == "content-md5" }
291
+ put destination_url, headers: headers, params: plaintext
292
+ assert_response :unprocessable_entity
293
+ end
294
+
295
+ test "update() refuses to upload if Content-MD5 from headers differs from the one in the token" do
296
+ rng = Random.new(Minitest.seed)
297
+ plaintext = rng.bytes(512)
298
+ b64_md5 = Digest::MD5.base64digest(plaintext)
299
+
300
+ key = rng.hex(12)
301
+ encryption_key = rng.bytes(65)
302
+
303
+ headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
304
+ destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key)
305
+
306
+ wrong_md5 = Digest::MD5.base64digest(plaintext + "t")
307
+ headers["content-md5"] = wrong_md5
308
+
309
+ put destination_url, headers: headers, params: plaintext
310
+ assert_response :unprocessable_entity
311
+ end
312
+
313
+ test "update() refuses to upload if no encryption key is present in the header" do
314
+ rng = Random.new(Minitest.seed)
315
+ plaintext = rng.bytes(512)
316
+ b64_md5 = Digest::MD5.base64digest(plaintext)
317
+
318
+ key = rng.hex(12)
319
+ encryption_key = rng.bytes(65)
320
+
321
+ headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
322
+ destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key)
323
+
324
+ headers.delete_if { |k, _| k.downcase == "x-active-storage-encryption-key" }
325
+ put destination_url, headers: headers, params: plaintext
326
+ assert_response :unprocessable_entity
327
+ end
328
+
329
+ test "update() refuses to upload if plaintext is different to the one the checksum has been calculated for" do
330
+ rng = Random.new(Minitest.seed)
331
+ plaintext = rng.bytes(512)
332
+ b64_md5 = Digest::MD5.base64digest(plaintext)
333
+
334
+ key = rng.hex(12)
335
+ encryption_key = rng.bytes(65)
336
+
337
+ headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
338
+ destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key)
339
+
340
+ different_plaintext = rng.bytes(plaintext.bytesize)
341
+ refute_equal different_plaintext, plaintext
342
+
343
+ put destination_url, headers: headers, params: different_plaintext
344
+ assert_response :unprocessable_entity
345
+
346
+ refute @service.exist?(key)
347
+ end
348
+
349
+ test "update() refuses to upload if plaintext has a different length than stated during token generation" do
350
+ rng = Random.new(Minitest.seed)
351
+ plaintext = rng.bytes(512)
352
+ b64_md5 = Digest::MD5.base64digest(plaintext)
353
+
354
+ key = rng.hex(12)
355
+ encryption_key = rng.bytes(65)
356
+
357
+ headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
358
+ destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: (plaintext.bytesize - 44), checksum: b64_md5, encryption_key: encryption_key)
359
+
360
+ put destination_url, headers: headers, params: plaintext
361
+ assert_response :unprocessable_entity
362
+ end
363
+
364
+ test "update() refuses to upload if the encryption key given in the header is different than the one used to generate the URL" do
365
+ rng = Random.new(Minitest.seed)
366
+ plaintext = rng.bytes(512)
367
+ b64_md5 = Digest::MD5.base64digest(plaintext)
368
+
369
+ key = rng.hex(12)
370
+ encryption_key = rng.bytes(65)
371
+
372
+ headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
373
+ destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key)
374
+
375
+ other_encryption_key = rng.bytes(65)
376
+ refute_equal other_encryption_key, encryption_key
377
+
378
+ headers["x-active-storage-encryption-key"] = Base64.strict_encode64(other_encryption_key)
379
+
380
+ put destination_url, headers: headers, params: plaintext
381
+ assert_response :unprocessable_entity
382
+ end
383
+
384
+ test "update() refuses to upload if the token in the URL has expired" do
385
+ rng = Random.new(Minitest.seed)
386
+ plaintext = rng.bytes(512)
387
+ b64_md5 = Digest::MD5.base64digest(plaintext)
388
+
389
+ key = rng.hex(12)
390
+ encryption_key = rng.bytes(65)
391
+
392
+ headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5)
393
+ destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key)
394
+
395
+ travel 10.seconds
396
+
397
+ put destination_url, headers: headers, params: plaintext
398
+ assert_response :unprocessable_entity
399
+ end
400
+ end