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.
- checksums.yaml +7 -0
- data/Appraisals +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +236 -0
- data/Rakefile +17 -0
- data/bin/rails +26 -0
- data/bin/rubocop +8 -0
- data/config/initializers/active_storage_encryption.rb +9 -0
- data/config/routes.rb +7 -0
- data/gemfiles/rails_7.gemfile +7 -0
- data/gemfiles/rails_7.gemfile.lock +276 -0
- data/gemfiles/rails_8.gemfile +7 -0
- data/gemfiles/rails_8.gemfile.lock +276 -0
- data/lib/active_storage/service/encrypted_disk_service.rb +10 -0
- data/lib/active_storage/service/encrypted_mirror_service.rb +10 -0
- data/lib/active_storage/service/encrypted_s3_service.rb +10 -0
- data/lib/active_storage_encryption/encrypted_blobs_controller.rb +163 -0
- data/lib/active_storage_encryption/encrypted_disk_service/v1_scheme.rb +28 -0
- data/lib/active_storage_encryption/encrypted_disk_service/v2_scheme.rb +51 -0
- data/lib/active_storage_encryption/encrypted_disk_service.rb +186 -0
- data/lib/active_storage_encryption/encrypted_mirror_service.rb +76 -0
- data/lib/active_storage_encryption/encrypted_s3_service.rb +236 -0
- data/lib/active_storage_encryption/engine.rb +7 -0
- data/lib/active_storage_encryption/overrides.rb +201 -0
- data/lib/active_storage_encryption/private_url_policy.rb +53 -0
- data/lib/active_storage_encryption/resumable_gcs_upload.rb +194 -0
- data/lib/active_storage_encryption/version.rb +5 -0
- data/lib/active_storage_encryption.rb +79 -0
- data/lib/tasks/active_storage_encryption_tasks.rake +6 -0
- data/test/active_storage_encryption_test.rb +9 -0
- data/test/dummy/Rakefile +8 -0
- data/test/dummy/app/assets/stylesheets/application.css +1 -0
- data/test/dummy/app/controllers/application_controller.rb +6 -0
- data/test/dummy/app/helpers/application_helper.rb +4 -0
- data/test/dummy/app/models/application_record.rb +5 -0
- data/test/dummy/app/views/layouts/application.html.erb +22 -0
- data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
- data/test/dummy/app/views/pwa/service-worker.js +26 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/bin/setup +37 -0
- data/test/dummy/config/application.rb +43 -0
- data/test/dummy/config/boot.rb +7 -0
- data/test/dummy/config/credentials.yml.enc +1 -0
- data/test/dummy/config/database.yml +32 -0
- data/test/dummy/config/environment.rb +7 -0
- data/test/dummy/config/environments/development.rb +59 -0
- data/test/dummy/config/environments/production.rb +81 -0
- data/test/dummy/config/environments/test.rb +53 -0
- data/test/dummy/config/initializers/content_security_policy.rb +27 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +10 -0
- data/test/dummy/config/initializers/inflections.rb +18 -0
- data/test/dummy/config/initializers/permissions_policy.rb +15 -0
- data/test/dummy/config/locales/en.yml +31 -0
- data/test/dummy/config/master.key +1 -0
- data/test/dummy/config/puma.rb +36 -0
- data/test/dummy/config/routes.rb +5 -0
- data/test/dummy/config/storage.yml +21 -0
- data/test/dummy/config.ru +8 -0
- data/test/dummy/db/migrate/20250304023851_create_active_storage_tables.active_storage.rb +60 -0
- data/test/dummy/db/migrate/20250304023853_add_blob_encryption_key_column.rb +7 -0
- data/test/dummy/db/schema.rb +47 -0
- data/test/dummy/log/test.log +1022 -0
- data/test/dummy/public/404.html +67 -0
- data/test/dummy/public/406-unsupported-browser.html +66 -0
- data/test/dummy/public/422.html +67 -0
- data/test/dummy/public/500.html +66 -0
- data/test/dummy/public/icon.png +0 -0
- data/test/dummy/public/icon.svg +3 -0
- data/test/dummy/storage/test.sqlite3 +0 -0
- data/test/dummy/storage/x6/pl/x6plznfuhrsyjn9pox2a6xgmcs3x +0 -0
- data/test/dummy/storage/yq/sv/yqsvw5a72b3fv719zq8a6yb7lv0j +0 -0
- data/test/integration/encrypted_blobs_controller_test.rb +400 -0
- data/test/lib/encrypted_disk_service_test.rb +387 -0
- data/test/lib/encrypted_mirror_service_test.rb +159 -0
- data/test/lib/encrypted_s3_service_test.rb +293 -0
- data/test/test_helper.rb +19 -0
- metadata +264 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 9c79ba1ae2b68a8d4e76c9d02092e426a41ddd414a968a082121cab647c2e239
|
4
|
+
data.tar.gz: ee60709d1843c4a814c52496a4fffcdec44b36bd3a48f18513fb0b56ba4a0c05
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 2d73fa7ea374c47f4fea531791db5962d469c8e1a95e9e7f9b0f64b9904e1b5948fd298b788b2a6c93a2e8beff0d58fde6ba24aa00c3c852b316232780a4ba4e
|
7
|
+
data.tar.gz: 7a2427499a85bec52196dfbdf304193618febcf7c3ceb0eb0944a01452683dbbb2ff2304a716ae3694fb9f55f6683c337b4d19189f67ce034abe8a58e093a0f5
|
data/Appraisals
ADDED
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright Cheddar Payments BV, Julik Tarkhanov, Sebastian van Hesteren
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,236 @@
|
|
1
|
+
## What does this do?
|
2
|
+
|
3
|
+
This library will enable the use of per-blob encryption keys with ActiveStorage. It enables file encryption with a separate encryption key generated for every `ActiveStorage::Blob`. It uses [CSEK](https://cloud.google.com/storage/docs/encryption/using-customer-supplied-keys) on Google Cloud, [SSE-C](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html#specifying-s3-c-encryption) on AWS, and [block_cipher_kit](https://rubygems.org/gems/block_cipher_kit) for files on disk to add a layer of encryption to every uploaded file. Every `Blob` gets its own, random encryption key.
|
4
|
+
|
5
|
+
During streaming download, either the cloud provider or a Rails controller will decrypt the requested chunk of the file as it gets served to the client.
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
|
9
|
+
Install the gem and run the migration.
|
10
|
+
|
11
|
+
```shell
|
12
|
+
bundle add active_storage_encryption
|
13
|
+
bin/rails active_storage_encryption:install
|
14
|
+
bin/rails db:migrate
|
15
|
+
```
|
16
|
+
|
17
|
+
Then, set up an encrypted service in `config/storage.yml` like so (in this example we use an `EncryptedDisk` service, which you can play around with in development):
|
18
|
+
|
19
|
+
```yaml
|
20
|
+
# storage.yml
|
21
|
+
encrypted_local_disk:
|
22
|
+
service: EncryptedDisk # this is the service implementation you need to use
|
23
|
+
private_url_policy: stream
|
24
|
+
root: <%= Rails.root.join("storage", "encrypted") %>
|
25
|
+
```
|
26
|
+
|
27
|
+
Then, on your model carrying the attachments:
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
class User < ApplicationRecord
|
31
|
+
has_one_attached :id_document_scan, service: :encrypted_local_disk
|
32
|
+
end
|
33
|
+
```
|
34
|
+
|
35
|
+
And.. that's it! For both GCS and S3, you will need to create an additional ActiveStorage service. The configuration is exactly the same as stock ActiveStorage services, with the addition of the `private_url_policy` [parameter.](#private-url-constraints)
|
36
|
+
|
37
|
+
Note that you need to appropriately configure CORS on your storage bucket to allow direct uploads [for S3](#S3Service) and for [GCP.](#EncryptedGCSService)
|
38
|
+
|
39
|
+
## How it works
|
40
|
+
|
41
|
+
This gem protects from a relatively common data breach scenario - cloud account access. Should an attacker gain access to your cloud storage bucket, the files stored there will be of no use to them without them also having a separate, specific encryption key for every file they want to retrieve.
|
42
|
+
|
43
|
+
The standard implementation of this usually works like this:
|
44
|
+
|
45
|
+
* You generate an encryption key which satisfies the provider's requirements
|
46
|
+
* You then send that key in a header along with your upload PUT request. The PUT request sends the file unencrypted to the provider, along with the key you have generated and signed.
|
47
|
+
* Before depositing your file in the bucket, the provider applies its encryption (and usually - some form of chunking, but that is transparent to you) to the data it receives from you
|
48
|
+
* Once the encrypted file is in the cloud storage, there is no plaintext version present anywhere
|
49
|
+
* Every read access to the file requires that you provide the encryption key
|
50
|
+
|
51
|
+
With this gem, you configure encrypted storage services in your `storage.yml` config, and run the included migration - which adds the `encryption_key` column to your `active_storage_blobs` table. Interactions with the cloud storage will then add the encryption key to all requests.
|
52
|
+
|
53
|
+
Once a `Blob` destined to be stored on an encrypted storage service (you set the `service:` where the blob should go in your `has_attached` calls) gets created in Rails, the blob will get a random encryption key generated for it. All further operations with the `Blob` are going to use that generated encryption key, automatically. None of the calls to the Blob will require the encryption key to be provided explicitly - everything should work as if you were dealing with a standard `ActiveStorage::Blob`.
|
54
|
+
|
55
|
+
This enables enhanced security for sensitive data, such as uploaded documents of all sorts, identity photos, biometric data and the like.
|
56
|
+
|
57
|
+
## Configuration
|
58
|
+
|
59
|
+
The use of encrypted file storage can be enabled by plugging an appropriate Service in your storage configuration. With the way the library works, your standard ActiveStorage services you already have will not magically start encrypting the data stored in them. It is recommended to define separate services for your sensitive data, as behaviour of the encrypted data stores differs in subtle ways from a standard ActiveStorage service.
|
60
|
+
|
61
|
+
## What this gem _can_ do
|
62
|
+
|
63
|
+
It is a tool for additional protection of sensitive files.
|
64
|
+
|
65
|
+
Normally, your cloud storage used for binary data will already support some form of encryption-at-rest, usually using the could provider's KMS (key management service). This is a sensible default, however it does not protect you from one important attack vector: a party obtaining access to your cloud storage bucket. If they do manage to obtain that access, they usually also have access to the KMS (by virtue of having access to a cloud account you control) and can bulk-download all of your data, unencrypted. All an attacker needs are cloud credentials for an account with "read" and "list" permissions.
|
66
|
+
|
67
|
+
With per-object encryption, however, just access to the bucket does not give the attacker much. Since every object is encrypted with a separate key, they need to have a key for every single file. That key is not stored in the provider's KMS, so even an account with KMS access won't be able to decrypt them.
|
68
|
+
|
69
|
+
Additionally, neither the cloud console (web UI) nor the API client will be able to download those objects without the keys.
|
70
|
+
|
71
|
+
The only way to obtain access would be for the attacker to have access to:
|
72
|
+
|
73
|
+
* A database dump of the `active_storage_blobs` table
|
74
|
+
* Your application's secrets (to decrypt the values in the table).
|
75
|
+
* The cloud storage bucket
|
76
|
+
|
77
|
+
It's way more work, and way more hassle. This is great for sensitive files, and increases security considerably.
|
78
|
+
|
79
|
+
## What this gem _cannot_ do
|
80
|
+
|
81
|
+
This gem does not provide an E2E encrypted solution. The file still gets encrypted by your cloud provider, and decrypted by your cloud provider. While it offers a strong protection _at rest_ it does not offer extra protection _in transit._ If you need that level of protection, you may want to look into [S3 client encryption](https://ankane.org/activestorage-s3-encryption) or other similar tech.
|
82
|
+
|
83
|
+
## Encrypted Service implementations
|
84
|
+
|
85
|
+
At the moment, we provide a few encrypted `Service` subclasses that you can use.
|
86
|
+
|
87
|
+
### EncryptedGCSService - Google Cloud Storage
|
88
|
+
|
89
|
+
The `EncryptedGCSService` supports most of the features of the stock `GCSService`:
|
90
|
+
|
91
|
+
* Upload and download
|
92
|
+
* Presigned PUT requests (direct upload)
|
93
|
+
* Preset metadata (content-disposition, content-type etc.)
|
94
|
+
|
95
|
+
Implementation details:
|
96
|
+
|
97
|
+
* Presigned URLs are subject to the [same constraints](#private-url-constraints) as the other providers. GCP will only serve you objects if you supply the headers. If you wish to generate URLs that can be used without headers, streaming goes through our provided controller.
|
98
|
+
* In the stock `Service` the `#compose` operation is "hopless": you tell GCP to "splice" multiple objects in-situ without having to download their content into your application. With encryption, `#compose` can't be performed "hoplessly" as the "compose" RPC call for encrypted objects requires the source objects be encrypted with the same encryption key - all of them. The resulting object will also be encrypted with that key. With this gem, every `Blob` gets encrypted with its own random key, so performing a `#compose` requires downloading the objects, decrypting them and reuploading the composed object. This gets done in a streaming manner to conserve disk space and memory (we provide a resumable upload client for GCS even though the official SDK does not), but the operation is no longer "hopless".
|
99
|
+
* You will need to enable the following headers in your bucket CORS configuration for `PUT` requests:
|
100
|
+
* `x-goog-encryption-algorithm`
|
101
|
+
* `x-goog-encryption-key`
|
102
|
+
* `x-goog-encryption-key-sha256`
|
103
|
+
|
104
|
+
### EncryptedS3Service - AWS S3
|
105
|
+
|
106
|
+
The `EncryptedS3Service` supports most of the features of the stock `S3Service`.
|
107
|
+
|
108
|
+
Implementation details:
|
109
|
+
|
110
|
+
* SSE-C is a feature that AWS provides. Other services offering S3-compatible object storage (Minio, Ceph...) may not support this feature - check the documentation of your provider.
|
111
|
+
* Presigned URLs are subject to the [same constraints](#private-url-constraints) as with GCS. S3 will only serve you objects if you supply the headers. If you wish to generate URLs that can be used without headers, streaming goes through our provided controller.
|
112
|
+
* The `#compose` operation is not hopless with S3, so there is no reduction in functionality vis-a-vis the standard `S3Service`.
|
113
|
+
* You will need to enable the following headers in your bucket CORS configuration for `PUT` requests:
|
114
|
+
* `x-amz-server-side-encryption-customer-algorithm`
|
115
|
+
* `x-amz-server-side-encryption-customer-key`
|
116
|
+
* `x-amz-server-side-encryption-customer-key-MD5`
|
117
|
+
|
118
|
+
### EncryptedDiskSevice - Filesystem
|
119
|
+
|
120
|
+
Can be used instead of the cloud services in development, or on the server if desired. The service will use AES-256-GCM encryption, with a way to switch to a different/more modern encryption scheme in the future.
|
121
|
+
|
122
|
+
Implementation details:
|
123
|
+
|
124
|
+
* Files will have the `.encrypted-v<N>` filename extension. The `v-<N>` stands for the version of the encryption scheme applied.
|
125
|
+
* Presigned URLs are subject to the [same constraints](#private-url-constraints) as the other providers. To resemble other encrypted Services, presigned URLs with header requirement will only be served by the provided controller if appropriate headers are sent with the request. If you wish to generate URLs that can be used without headers, streaming goes through our provided controller.
|
126
|
+
* The schemes for encryption are in the `block_cipher_kit` gem. Currently we use AES-256-GCM for blobs, with the authentication tag verified in case of a full download. Random access uses AES-256-CTR.
|
127
|
+
* A SHA2 digest of the encryption key is stored at the beginning of the encrypted file. This is used to deny download rapidly if an incorrect encryption key is provided.
|
128
|
+
* The IV is stored at the start of the ciphertext, after the digest of the encryption key. The IV gets generated at random for every Blob being encrypted.
|
129
|
+
|
130
|
+
## Additional information
|
131
|
+
|
132
|
+
* The stored `digest` (the Base64-encoded MD5 of the blob) will be for the plaintext file contents. This reduces security somewhat, because MD5 has known collisions and facilitates (to some extend) mounting a "known plaintext" attack.
|
133
|
+
* The stored `filesize` will be for the plaintext. This, again, facilitates an attack somewhat.
|
134
|
+
|
135
|
+
## Downloading the plaintext file contents in full or in part
|
136
|
+
|
137
|
+
Data will be automatically decrypted using the correct key on `Blob#open` or `Blob#download`. `EncryptedDiskSevice` will also apply the GCM validation check at the end of download/read (authenticate the cipher).
|
138
|
+
|
139
|
+
All of our encrypted Services support `download_range` for random access to the blob's plaintext. The decryption will be done in a streaming manner, without buffering:
|
140
|
+
|
141
|
+
* Cloud providers give you access to plaintext segments of the file using the HTTP `Range:` header (ranges are in plaintext offsets)
|
142
|
+
* `EncryptedDiskSevice` provides the same, but inside the app will access OS files - decrypting them in a streaming manner.
|
143
|
+
|
144
|
+
## Constraints with encrypted Blobs
|
145
|
+
|
146
|
+
There are also subtle differences in how cloud providers treat encrypted objects in their object storage vs. how other objects are treated, as well as which facilities change or become unavailable once your object is encrypted. Additionally, some ActiveStorage operations change semantics or start requiring an `encryption_key:` argument. Understanding those limitations is key to using active_storage_encryption correctly and effectively.
|
147
|
+
|
148
|
+
Key differences are as follows:
|
149
|
+
|
150
|
+
* If a Service supports encryption, _every_ blob stored on it will be encrypted. No exceptions. You cannot supply an encryption key of `nil` to bypass encryption on that service.
|
151
|
+
* A blob stored onto - or retrieved from - an encrypted Service must have an `#encryption_key` that is not `nil`
|
152
|
+
* Most operations performed on an encrypted Service must supply the encryption key, or multiple encryption keys (in case of `Service#compose`)
|
153
|
+
* An encrypted Service cannot be `public: true` - no CDNs we are aware of can proxy objects with per-object encryption keys.
|
154
|
+
* An encrypted Service cannot generate a signed GET URL unless you let that URL go through a streaming controller (we provide one), or you are going to send headers to the cloud providers' download endpoint. We default to using the streaming controller, which may cause a performance impact on your app due to slow clients. See more [here.](#private-url-constraints)
|
155
|
+
* Objects using per-object encryption are usually **inaccessible for cloud automation** - for example, scripts that load files into a cloud database using paths to the bucket will likely not work, as data is no longer readable for them. There will also be limitations for CLI clients. For example, the `gcloud` CLI only allows you to supply 100 encryption keys, and thus will only be able to download 100 objects at once.
|
156
|
+
|
157
|
+
## Private URL constraints
|
158
|
+
|
159
|
+
Both major cloud providers (S3 and GCP cloud storage) disallow using signed GET URLs unless you also supply the encryption key (and encryption parameters, as well as the key checksum) in the GET request headers. This has _severe_ implications for use of `Blob#url` and `rails_blob_path` / `rails_blob_url`, namely:
|
160
|
+
|
161
|
+
* You cannot redirect to a signed GET URL for an ActiveStorage blob (for downloading). Standard Rails `blob_path` helpers lead to an ActiveStorage controller which will try to redirect your browser to such a URL, but the browser will then receive a `403 Forbidden` response.
|
162
|
+
* You can no longer use a signed GET URL for an ActiveStorage blob as the `src` attribute for an `img`, `video` or `audio` element
|
163
|
+
|
164
|
+
Cloud providers presumably disallow supplying the encryption key inside the URL itself because they want to prevent those URLs gettng saved in web server / load balancer logs, and from being shared. This is a valid security concern, as most URL signing schemes are just for _signing_ but not for _encryption._ An encryption key of this nature could also be retained by a malicious party and reused.
|
165
|
+
|
166
|
+
However, for practical purposes you _may_ want to permit such URLs to be generated by your application, with very limited expiry time. We allow for this, with an associated limitation that the blob binary data **is then going to be streamed.** In that setup your Rails app functions as a streaming proxy, which will perform the request to cloud storage - passing along the requisite credentials - and stream the output to your client. This may not be the most performant way to stream data, but when per-file encryption is required this usually concerns sensitive files, which are not very widely shared anyway. We believe streaming to be a sensible compromise. Note that you want the streaming URLs to be short-lived!
|
167
|
+
|
168
|
+
To configure this facility, every encrypted `Service` we provide supports the `private_url_policy` configuration parameter. The possible values are as follows:
|
169
|
+
|
170
|
+
* `private_url_policy: disable` will make every call to `Blob#url` raise an exception. This will be raised by the stock Rails `ActiveStorage::Blobs::RedirectController#show`
|
171
|
+
* `private_url_policy: require_headers` will generate signed URLS, and you will need to ensure these URLs are only requested with the correct HTTP headers. The URLs will not expose the encryption key. When trying to use `rails_blob_path` you will end up receiving a 403 from the cloud storage provider after the redirect. You still may want to generate those URLs if you want to use them elsewhere and will be willing to manually add HTTP headers to the request.
|
172
|
+
* `private_url_policy: stream` will stream the decrypted Blob through our Rails controller. The URLs to that controller will not expose the encryption key. `rails_blob_path` will work, and generate a URL to the stock Rails `ActiveStorage::Blobs::RedirectController#show` action. That action, in turn, will generate a URL leading to `ActiveStorageEncryption::EncryptedBlobsController#show`. That action will stream out your file from whichever encrypted service your Blob is using.
|
173
|
+
|
174
|
+
For using the `require_headers` option you may want to use `Blob#headers_for_private_download` method - it will return you a `Hash` of headers that have to be supplied along with your request to the signed URL of the cloud service.
|
175
|
+
|
176
|
+
## Key exposure on upload
|
177
|
+
|
178
|
+
Both implementations of customer-supplied encryption keys (S3 and GCP) sign the checksum of the encryption key issued to the uploading client, so that the client may not alter the encryption key your application has issued. However, neither of them support key wrapping - encrypting the key before giving it to the client for performing the upload. GCP does support key wrapping, but only for its Compute Platform, and not for Cloud Storage. Therefore, the uploading client (the one that performs the PUT to the cloud storage or to our controller) is going to be able to decode and retain the raw encryption key.
|
179
|
+
|
180
|
+
You can, of course, rewrite the object in storage to decrypt it and re-encrypt it with a new encryption key, which the original uploader does not possess. This takes extra resources, however.
|
181
|
+
|
182
|
+
## Key exposure on download
|
183
|
+
|
184
|
+
When we stream through the controller, we encrypt the token instead of just signing it. This conceals the encryption key of the Blob, and uses standard Rails encryption. For our purposes we consider this configuration sufficiently secure.
|
185
|
+
|
186
|
+
## Direct uploads
|
187
|
+
|
188
|
+
All supported services accept the encryption key as part of the headers for the PUT direct upload. The provided Service implementations will generate the correct headers for you. However, your upload client _must_ use the headers provided to you by the server, and not invent its own. The standard ActiveStorage JS bundles honor those headers - but if you use your own uploader you will need to ensure it honors and forwards the headers too.
|
189
|
+
|
190
|
+
We also configure the services to generate _and_ require the `Content-MD5` header. The client doing the PUT will need to precompute the MD5 for the object before starting the PUT request or getting a PUT url (as the checksum gets signed by the cloud SDK or by our `EncryptedDiskService`). This is so that any data transmission errors can be intercepted early (we know of one case where a bug in Ruby, combined with a particular HTTP client library, led to bytes getting uploaded out of order).
|
191
|
+
|
192
|
+
## Security considerations / notes
|
193
|
+
|
194
|
+
* It is imperative that you let the server generate the encryption key, and not generate one on the client. Where possible, we add the headers for the encryption key to the signed URL parameters, so client generated keys will deliberately _not_ function. Letting the client generate the keys can lead to key reuse (unintentional or - worse - intentional).
|
195
|
+
* The key used is _not_ a passphrase or a password. It is a high-entropy bag of bytes, generated for every blob separately using `SecureRandom`. We generate more bytes than the cloud providers usually expect (32 bytes for AES) and take the starting bytes off that longer key - that is to allow mirroring to services that have varying key lengths, using the same encryption key.
|
196
|
+
* To the best of our knowledge, both S3 and GCS use AES-256-GCM (or a variation thereof) for encryption. Random access to GCM blocks requires dropping the auth tag validation (cipher authentication) of GCM, and downgrades it to CTR. We find this an acceptable tradeoff to enable random access.
|
197
|
+
* You must use `attribute_encrypted` and encrypt your `encryption_key` in the `active_storage_blobs` table, otherwise your data is not really safe in case a database dump gets exfiltrated.
|
198
|
+
* active_storage_encryption has not been verified by information security experts or cryptographers. While we do our best to have it be robust and secure, mishaps may happen. You have been warned.
|
199
|
+
* While cloud providers do publish some information about their encryption approaches, we do not know several crucial details allowing one to say that "this encryption scheme is secure enough for our needs". Namely:
|
200
|
+
* Neither GCP nor AWS say how the IV gets generated. A compromised IV (repeated IV) simplifies breaking a block cipher considerably.
|
201
|
+
* Neither GCP nor AWS say when they reset the IV. Counter-based IVs have a limit on the number of counter values and blocks that they support. For GCM and CTR the practical limit is around 64GB per encrypted message. To add extra safety, it is sometimes advised to "stitch" the message from multiple messages with the IV getting regenerated at random, for every message. If providers do use such "chunking", we could not find information about the size of the chunks nor the mechanics by which the IV gets generated for them.
|
202
|
+
|
203
|
+
Finally: this gem has not been verified by an information security expert or a cryptographer. While we did take all the possible precautions with regards to producing a secure design, we are all humans and might have omitted something.
|
204
|
+
|
205
|
+
## Mirroring
|
206
|
+
|
207
|
+
We provide a version of the `EncryptedMirrorService` which is going to use the same encryption key when mirroring to multiple services. It needs some modifications in comparison to a standard `MirrorService` because if any services it mirrors to use encryption, it needs an encryption key to be provided upstream for all write operations - so that it can be passed on to downstream Services. You can only mirror an encrypted `Blob` to encrypted services.
|
208
|
+
|
209
|
+
## Key truncation
|
210
|
+
|
211
|
+
In practice, all services use some form of AES-256 and therefore use a 32-byte encryption key. However, we can't exclude the possibility that there will be support for newer encryption schemes in the future with a longer encryption key being available. Because we potentially may need to allow a `Blob` to be encrypted and decrypted by service A, and then encrypted by service B, there is a possibility that key length requirements for those services could differ. Therefore, we generate a longer `encryption_key` (from the same random byte source) than strictly necessary for AES-256 and save it in your `active_storage_blobs` table. This key then gets truncated to satisfy the key length requirements of a particular encrypted Service.
|
212
|
+
|
213
|
+
This has an important implication for how the Service classes are written: they need to truncate the encryption keys to their conformant length themselves.
|
214
|
+
|
215
|
+
Important, once again: **do not use passwords for this encryption key.** If you really want to use passwords, use something like PBKDF (available via [ActiveSupport::KeyGenerator](https://api.rubyonrails.org/classes/ActiveSupport/KeyGenerator.html) to derive a high-entropy key from your passphrase in a safe manner.
|
216
|
+
|
217
|
+
## Storing your encryption keys securely
|
218
|
+
|
219
|
+
The `encryption_key` must be subjected to ActiveRecord attribute encryption. Of course, you may not do it, but... no - wait. Once more:
|
220
|
+
|
221
|
+
> [!WARNING]
|
222
|
+
> The `ActiveStorage::Blob#encryption_key` attribute must be an encrypted ActiveRecord attribute. Do set up your attribute encryption.
|
223
|
+
|
224
|
+
The value in that column is what's called "key material". It is highly sensitive and the attacker obtaining a database dump will immediately have unfettered access to all the encryption keys, for all Blobs that you have. You can refer to [this guide](https://guides.rubyonrails.org/active_record_encryption.html) on how to set up attribute encryption. The variant you want is **non-deterministic encryption** (you likely do not want to search for a specific encryption key in your database).
|
225
|
+
|
226
|
+
## Migrating your blobs into an encrypted store
|
227
|
+
|
228
|
+
We provide a method on the `Blob` for this - called `migrate_to_encrypted_service(service)`. The method will:
|
229
|
+
|
230
|
+
* Generate an `encryption_key` for the blob in question
|
231
|
+
* Stream the plaintext data into a copy on the encrypted service, applying encryption
|
232
|
+
* Transactionally store the encryption key on the `Blob` _and_ switch its `service` to the encrypted service.
|
233
|
+
|
234
|
+
|
235
|
+
## License
|
236
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "bundler/gem_tasks"
|
5
|
+
|
6
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
7
|
+
load "rails/tasks/engine.rake"
|
8
|
+
load "rails/tasks/statistics.rake"
|
9
|
+
|
10
|
+
require "bundler/gem_tasks"
|
11
|
+
|
12
|
+
task :format do
|
13
|
+
`bundle exec standardrb --fix`
|
14
|
+
`bundle exec magic_frozen_string_literal .`
|
15
|
+
end
|
16
|
+
|
17
|
+
task default: ["app:test"]
|
data/bin/rails
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# This command will automatically be run when you run "rails" with Rails gems
|
3
|
+
# installed from the root of your application.
|
4
|
+
|
5
|
+
ENGINE_ROOT = File.expand_path("..", __dir__)
|
6
|
+
ENGINE_PATH = File.expand_path("../lib/active_storage_encryption/engine", __dir__)
|
7
|
+
APP_PATH = File.expand_path("../test/dummy/config/application", __dir__)
|
8
|
+
|
9
|
+
# Set up gems listed in the Gemfile.
|
10
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
11
|
+
require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
|
12
|
+
|
13
|
+
require "rails"
|
14
|
+
# Pick the frameworks you want:
|
15
|
+
require "active_model/railtie"
|
16
|
+
# require "active_job/railtie"
|
17
|
+
require "active_record/railtie"
|
18
|
+
# require "active_storage/engine"
|
19
|
+
require "action_controller/railtie"
|
20
|
+
# require "action_mailer/railtie"
|
21
|
+
# require "action_mailbox/engine"
|
22
|
+
# require "action_text/engine"
|
23
|
+
require "action_view/railtie"
|
24
|
+
# require "action_cable/engine"
|
25
|
+
require "rails/test_unit/railtie"
|
26
|
+
require "rails/engine/commands"
|
data/bin/rubocop
ADDED
@@ -0,0 +1,8 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require "rubygems"
|
3
|
+
require "bundler/setup"
|
4
|
+
|
5
|
+
# explicit rubocop config increases performance slightly while avoiding config confusion.
|
6
|
+
ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
|
7
|
+
|
8
|
+
load Gem.bin_path("rubocop", "rubocop")
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
ActiveSupport::Reloader.to_prepare do
|
4
|
+
require "active_storage_encryption"
|
5
|
+
ActiveStorage::Blob.send(:include, ActiveStorageEncryption::Overrides::EncryptedBlobClassMethods)
|
6
|
+
ActiveStorage::Blob.send(:prepend, ActiveStorageEncryption::Overrides::EncryptedBlobInstanceMethods)
|
7
|
+
ActiveStorage::Blob::Identifiable.send(:prepend, ActiveStorageEncryption::Overrides::BlobIdentifiableInstanceMethods)
|
8
|
+
ActiveStorage::Downloader.send(:prepend, ActiveStorageEncryption::Overrides::DownloaderInstanceMethods)
|
9
|
+
end
|
data/config/routes.rb
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
ActiveStorageEncryption::Engine.routes.draw do
|
4
|
+
put "/blob/:token", to: "encrypted_blobs#update", as: "encrypted_blob_put"
|
5
|
+
get "/blob/:token/*filename(.:format)", to: "encrypted_blobs#show", as: "encrypted_blob_streaming_get"
|
6
|
+
post "/blob/direct-uploads", to: "encrypted_blobs#create_direct_upload", as: "create_encrypted_blob_direct_upload"
|
7
|
+
end
|
@@ -0,0 +1,276 @@
|
|
1
|
+
PATH
|
2
|
+
remote: ..
|
3
|
+
specs:
|
4
|
+
active_storage_encryption (0.1.0)
|
5
|
+
activestorage
|
6
|
+
block_cipher_kit (>= 0.0.4)
|
7
|
+
rails (>= 7.2.2.1)
|
8
|
+
|
9
|
+
GEM
|
10
|
+
remote: https://rubygems.org/
|
11
|
+
specs:
|
12
|
+
actioncable (7.2.2.1)
|
13
|
+
actionpack (= 7.2.2.1)
|
14
|
+
activesupport (= 7.2.2.1)
|
15
|
+
nio4r (~> 2.0)
|
16
|
+
websocket-driver (>= 0.6.1)
|
17
|
+
zeitwerk (~> 2.6)
|
18
|
+
actionmailbox (7.2.2.1)
|
19
|
+
actionpack (= 7.2.2.1)
|
20
|
+
activejob (= 7.2.2.1)
|
21
|
+
activerecord (= 7.2.2.1)
|
22
|
+
activestorage (= 7.2.2.1)
|
23
|
+
activesupport (= 7.2.2.1)
|
24
|
+
mail (>= 2.8.0)
|
25
|
+
actionmailer (7.2.2.1)
|
26
|
+
actionpack (= 7.2.2.1)
|
27
|
+
actionview (= 7.2.2.1)
|
28
|
+
activejob (= 7.2.2.1)
|
29
|
+
activesupport (= 7.2.2.1)
|
30
|
+
mail (>= 2.8.0)
|
31
|
+
rails-dom-testing (~> 2.2)
|
32
|
+
actionpack (7.2.2.1)
|
33
|
+
actionview (= 7.2.2.1)
|
34
|
+
activesupport (= 7.2.2.1)
|
35
|
+
nokogiri (>= 1.8.5)
|
36
|
+
racc
|
37
|
+
rack (>= 2.2.4, < 3.2)
|
38
|
+
rack-session (>= 1.0.1)
|
39
|
+
rack-test (>= 0.6.3)
|
40
|
+
rails-dom-testing (~> 2.2)
|
41
|
+
rails-html-sanitizer (~> 1.6)
|
42
|
+
useragent (~> 0.16)
|
43
|
+
actiontext (7.2.2.1)
|
44
|
+
actionpack (= 7.2.2.1)
|
45
|
+
activerecord (= 7.2.2.1)
|
46
|
+
activestorage (= 7.2.2.1)
|
47
|
+
activesupport (= 7.2.2.1)
|
48
|
+
globalid (>= 0.6.0)
|
49
|
+
nokogiri (>= 1.8.5)
|
50
|
+
actionview (7.2.2.1)
|
51
|
+
activesupport (= 7.2.2.1)
|
52
|
+
builder (~> 3.1)
|
53
|
+
erubi (~> 1.11)
|
54
|
+
rails-dom-testing (~> 2.2)
|
55
|
+
rails-html-sanitizer (~> 1.6)
|
56
|
+
activejob (7.2.2.1)
|
57
|
+
activesupport (= 7.2.2.1)
|
58
|
+
globalid (>= 0.3.6)
|
59
|
+
activemodel (7.2.2.1)
|
60
|
+
activesupport (= 7.2.2.1)
|
61
|
+
activerecord (7.2.2.1)
|
62
|
+
activemodel (= 7.2.2.1)
|
63
|
+
activesupport (= 7.2.2.1)
|
64
|
+
timeout (>= 0.4.0)
|
65
|
+
activestorage (7.2.2.1)
|
66
|
+
actionpack (= 7.2.2.1)
|
67
|
+
activejob (= 7.2.2.1)
|
68
|
+
activerecord (= 7.2.2.1)
|
69
|
+
activesupport (= 7.2.2.1)
|
70
|
+
marcel (~> 1.0)
|
71
|
+
activesupport (7.2.2.1)
|
72
|
+
base64
|
73
|
+
benchmark (>= 0.3)
|
74
|
+
bigdecimal
|
75
|
+
concurrent-ruby (~> 1.0, >= 1.3.1)
|
76
|
+
connection_pool (>= 2.2.5)
|
77
|
+
drb
|
78
|
+
i18n (>= 1.6, < 2)
|
79
|
+
logger (>= 1.4.2)
|
80
|
+
minitest (>= 5.1)
|
81
|
+
securerandom (>= 0.3)
|
82
|
+
tzinfo (~> 2.0, >= 2.0.5)
|
83
|
+
appraisal (2.5.0)
|
84
|
+
bundler
|
85
|
+
rake
|
86
|
+
thor (>= 0.14.0)
|
87
|
+
ast (2.4.2)
|
88
|
+
aws-eventstream (1.3.1)
|
89
|
+
aws-partitions (1.1060.0)
|
90
|
+
aws-sdk-core (3.220.0)
|
91
|
+
aws-eventstream (~> 1, >= 1.3.0)
|
92
|
+
aws-partitions (~> 1, >= 1.992.0)
|
93
|
+
aws-sigv4 (~> 1.9)
|
94
|
+
base64
|
95
|
+
jmespath (~> 1, >= 1.6.1)
|
96
|
+
aws-sdk-kms (1.99.0)
|
97
|
+
aws-sdk-core (~> 3, >= 3.216.0)
|
98
|
+
aws-sigv4 (~> 1.5)
|
99
|
+
aws-sdk-s3 (1.182.0)
|
100
|
+
aws-sdk-core (~> 3, >= 3.216.0)
|
101
|
+
aws-sdk-kms (~> 1)
|
102
|
+
aws-sigv4 (~> 1.5)
|
103
|
+
aws-sigv4 (1.11.0)
|
104
|
+
aws-eventstream (~> 1, >= 1.0.2)
|
105
|
+
base64 (0.2.0)
|
106
|
+
benchmark (0.4.0)
|
107
|
+
bigdecimal (3.1.9)
|
108
|
+
block_cipher_kit (0.0.4)
|
109
|
+
builder (3.3.0)
|
110
|
+
concurrent-ruby (1.3.5)
|
111
|
+
connection_pool (2.5.0)
|
112
|
+
crass (1.0.6)
|
113
|
+
date (3.4.1)
|
114
|
+
drb (2.2.1)
|
115
|
+
erubi (1.13.1)
|
116
|
+
globalid (1.2.1)
|
117
|
+
activesupport (>= 6.1)
|
118
|
+
i18n (1.14.7)
|
119
|
+
concurrent-ruby (~> 1.0)
|
120
|
+
io-console (0.8.0)
|
121
|
+
irb (1.15.1)
|
122
|
+
pp (>= 0.6.0)
|
123
|
+
rdoc (>= 4.0.0)
|
124
|
+
reline (>= 0.4.2)
|
125
|
+
jmespath (1.6.2)
|
126
|
+
json (2.10.1)
|
127
|
+
language_server-protocol (3.17.0.4)
|
128
|
+
lint_roller (1.1.0)
|
129
|
+
logger (1.6.6)
|
130
|
+
loofah (2.24.0)
|
131
|
+
crass (~> 1.0.2)
|
132
|
+
nokogiri (>= 1.12.0)
|
133
|
+
magic_frozen_string_literal (1.2.0)
|
134
|
+
mail (2.8.1)
|
135
|
+
mini_mime (>= 0.1.1)
|
136
|
+
net-imap
|
137
|
+
net-pop
|
138
|
+
net-smtp
|
139
|
+
marcel (1.0.4)
|
140
|
+
mini_mime (1.1.5)
|
141
|
+
minitest (5.25.4)
|
142
|
+
net-http (0.6.0)
|
143
|
+
uri
|
144
|
+
net-imap (0.5.6)
|
145
|
+
date
|
146
|
+
net-protocol
|
147
|
+
net-pop (0.1.2)
|
148
|
+
net-protocol
|
149
|
+
net-protocol (0.2.2)
|
150
|
+
timeout
|
151
|
+
net-smtp (0.5.1)
|
152
|
+
net-protocol
|
153
|
+
nio4r (2.7.4)
|
154
|
+
nokogiri (1.18.3-x86_64-darwin)
|
155
|
+
racc (~> 1.4)
|
156
|
+
nokogiri (1.18.3-x86_64-linux-gnu)
|
157
|
+
racc (~> 1.4)
|
158
|
+
parallel (1.26.3)
|
159
|
+
parser (3.3.7.1)
|
160
|
+
ast (~> 2.4.1)
|
161
|
+
racc
|
162
|
+
pp (0.6.2)
|
163
|
+
prettyprint
|
164
|
+
prettyprint (0.2.0)
|
165
|
+
psych (5.2.3)
|
166
|
+
date
|
167
|
+
stringio
|
168
|
+
racc (1.8.1)
|
169
|
+
rack (3.1.11)
|
170
|
+
rack-session (2.1.0)
|
171
|
+
base64 (>= 0.1.0)
|
172
|
+
rack (>= 3.0.0)
|
173
|
+
rack-test (2.2.0)
|
174
|
+
rack (>= 1.3)
|
175
|
+
rackup (2.2.1)
|
176
|
+
rack (>= 3)
|
177
|
+
rails (7.2.2.1)
|
178
|
+
actioncable (= 7.2.2.1)
|
179
|
+
actionmailbox (= 7.2.2.1)
|
180
|
+
actionmailer (= 7.2.2.1)
|
181
|
+
actionpack (= 7.2.2.1)
|
182
|
+
actiontext (= 7.2.2.1)
|
183
|
+
actionview (= 7.2.2.1)
|
184
|
+
activejob (= 7.2.2.1)
|
185
|
+
activemodel (= 7.2.2.1)
|
186
|
+
activerecord (= 7.2.2.1)
|
187
|
+
activestorage (= 7.2.2.1)
|
188
|
+
activesupport (= 7.2.2.1)
|
189
|
+
bundler (>= 1.15.0)
|
190
|
+
railties (= 7.2.2.1)
|
191
|
+
rails-dom-testing (2.2.0)
|
192
|
+
activesupport (>= 5.0.0)
|
193
|
+
minitest
|
194
|
+
nokogiri (>= 1.6)
|
195
|
+
rails-html-sanitizer (1.6.2)
|
196
|
+
loofah (~> 2.21)
|
197
|
+
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
198
|
+
railties (7.2.2.1)
|
199
|
+
actionpack (= 7.2.2.1)
|
200
|
+
activesupport (= 7.2.2.1)
|
201
|
+
irb (~> 1.13)
|
202
|
+
rackup (>= 1.0.0)
|
203
|
+
rake (>= 12.2)
|
204
|
+
thor (~> 1.0, >= 1.2.2)
|
205
|
+
zeitwerk (~> 2.6)
|
206
|
+
rainbow (3.1.1)
|
207
|
+
rake (13.2.1)
|
208
|
+
rdoc (6.12.0)
|
209
|
+
psych (>= 4.0.0)
|
210
|
+
regexp_parser (2.10.0)
|
211
|
+
reline (0.6.0)
|
212
|
+
io-console (~> 0.5)
|
213
|
+
rubocop (1.71.2)
|
214
|
+
json (~> 2.3)
|
215
|
+
language_server-protocol (>= 3.17.0)
|
216
|
+
parallel (~> 1.10)
|
217
|
+
parser (>= 3.3.0.2)
|
218
|
+
rainbow (>= 2.2.2, < 4.0)
|
219
|
+
regexp_parser (>= 2.9.3, < 3.0)
|
220
|
+
rubocop-ast (>= 1.38.0, < 2.0)
|
221
|
+
ruby-progressbar (~> 1.7)
|
222
|
+
unicode-display_width (>= 2.4.0, < 4.0)
|
223
|
+
rubocop-ast (1.38.1)
|
224
|
+
parser (>= 3.3.1.0)
|
225
|
+
rubocop-performance (1.23.1)
|
226
|
+
rubocop (>= 1.48.1, < 2.0)
|
227
|
+
rubocop-ast (>= 1.31.1, < 2.0)
|
228
|
+
ruby-progressbar (1.13.0)
|
229
|
+
securerandom (0.4.1)
|
230
|
+
sqlite3 (2.6.0-x86_64-darwin)
|
231
|
+
sqlite3 (2.6.0-x86_64-linux-gnu)
|
232
|
+
standard (1.45.0)
|
233
|
+
language_server-protocol (~> 3.17.0.2)
|
234
|
+
lint_roller (~> 1.0)
|
235
|
+
rubocop (~> 1.71.0)
|
236
|
+
standard-custom (~> 1.0.0)
|
237
|
+
standard-performance (~> 1.6)
|
238
|
+
standard-custom (1.0.2)
|
239
|
+
lint_roller (~> 1.0)
|
240
|
+
rubocop (~> 1.50)
|
241
|
+
standard-performance (1.6.0)
|
242
|
+
lint_roller (~> 1.1)
|
243
|
+
rubocop-performance (~> 1.23.0)
|
244
|
+
stringio (3.1.5)
|
245
|
+
thor (1.3.2)
|
246
|
+
timeout (0.4.3)
|
247
|
+
tzinfo (2.0.6)
|
248
|
+
concurrent-ruby (~> 1.0)
|
249
|
+
unicode-display_width (3.1.4)
|
250
|
+
unicode-emoji (~> 4.0, >= 4.0.4)
|
251
|
+
unicode-emoji (4.0.4)
|
252
|
+
uri (1.0.3)
|
253
|
+
useragent (0.16.11)
|
254
|
+
websocket-driver (0.7.7)
|
255
|
+
base64
|
256
|
+
websocket-extensions (>= 0.1.0)
|
257
|
+
websocket-extensions (0.1.5)
|
258
|
+
zeitwerk (2.7.2)
|
259
|
+
|
260
|
+
PLATFORMS
|
261
|
+
x86_64-darwin
|
262
|
+
x86_64-linux
|
263
|
+
|
264
|
+
DEPENDENCIES
|
265
|
+
active_storage_encryption!
|
266
|
+
appraisal
|
267
|
+
aws-sdk-s3
|
268
|
+
magic_frozen_string_literal
|
269
|
+
net-http
|
270
|
+
rails (< 8.0)
|
271
|
+
rake
|
272
|
+
sqlite3
|
273
|
+
standard (>= 1.35.1)
|
274
|
+
|
275
|
+
BUNDLED WITH
|
276
|
+
2.5.11
|