active_cipher_storage 1.0.2 → 2.0.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 +4 -4
- data/CHANGELOG.md +34 -6
- data/CONTRIBUTING.md +0 -1
- data/README.md +185 -187
- data/lib/active_cipher_storage/adapters/s3_adapter.rb +11 -7
- data/lib/active_cipher_storage/blob_metadata.rb +16 -47
- data/lib/active_cipher_storage/configuration.rb +59 -26
- data/lib/active_cipher_storage/engine.rb +1 -17
- data/lib/active_cipher_storage/errors.rb +2 -2
- data/lib/active_cipher_storage/multipart_upload.rb +12 -10
- data/lib/active_cipher_storage/providers/aws_kms_provider.rb +22 -38
- data/lib/active_cipher_storage/providers/base.rb +2 -13
- data/lib/active_cipher_storage/providers/env_provider.rb +11 -34
- data/lib/active_cipher_storage/stream_cipher.rb +5 -2
- data/lib/active_cipher_storage/version.rb +1 -1
- data/lib/active_cipher_storage.rb +0 -2
- data/lib/active_storage/service/active_cipher_storage_service.rb +181 -4
- metadata +2 -4
- data/lib/active_cipher_storage/adapters/active_storage_service.rb +0 -140
- data/lib/active_cipher_storage/key_rotation.rb +0 -121
data/README.md
CHANGED
|
@@ -1,83 +1,86 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Active Cipher Storage
|
|
2
2
|
|
|
3
3
|
[](https://github.com/codebyjass/active-cipher-storage/actions/workflows/ruby.yml)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Active Cipher Storage is published as the `active_cipher_storage` Ruby gem.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
It adds Rails Active Storage encryption and decryption without changing the way your Rails app attaches files. Files are encrypted before they are stored in AWS S3 or another storage service, and decrypted when your app reads them back.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
This solves a common Rails security problem: sensitive files should be protected before they leave your application.
|
|
10
|
+
|
|
11
|
+
It works with normal Rails Active Storage attachments, direct S3 uploads from Ruby service objects, streaming downloads, and backend-managed multipart uploads for large files.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- Encrypt files before uploading them to S3 or Active Storage.
|
|
16
|
+
- Decrypt files automatically when downloading.
|
|
17
|
+
- Works with Rails Active Storage.
|
|
18
|
+
- Supports direct AWS S3 client usage.
|
|
19
|
+
- Handles large files with streaming AES-256-GCM encryption.
|
|
20
|
+
- Supports backend-managed multipart uploads for frontend chunk upload flows.
|
|
21
|
+
- Uses pluggable key providers: environment variables, AWS KMS, or custom KMS providers.
|
|
22
|
+
|
|
23
|
+
## Use Cases
|
|
24
|
+
|
|
25
|
+
- Encrypt user documents before storing them in S3.
|
|
26
|
+
- Secure financial records, contracts, medical files, invoices, and other sensitive uploads.
|
|
27
|
+
- Add application-level encryption on top of AWS S3 server-side encryption.
|
|
28
|
+
- Keep Rails Active Storage APIs while storing encrypted files.
|
|
29
|
+
- Stream large encrypted files from S3 without loading the whole file into memory.
|
|
30
|
+
- Meet compliance and privacy requirements around PII, GDPR, HIPAA-style data, or internal security policies.
|
|
12
31
|
|
|
13
32
|
## Contents
|
|
14
33
|
|
|
15
|
-
1. [
|
|
16
|
-
2. [
|
|
17
|
-
3. [
|
|
18
|
-
4. [
|
|
19
|
-
5. [
|
|
20
|
-
6. [
|
|
21
|
-
7. [
|
|
22
|
-
8. [
|
|
23
|
-
9. [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
12. [
|
|
30
|
-
13. [
|
|
31
|
-
14. [
|
|
32
|
-
15. [
|
|
33
|
-
16. [
|
|
34
|
-
17. [
|
|
35
|
-
18. [
|
|
34
|
+
1. [Features](#features)
|
|
35
|
+
2. [Use Cases](#use-cases)
|
|
36
|
+
3. [How it works](#how-it-works)
|
|
37
|
+
4. [Installation](#installation)
|
|
38
|
+
5. [Rails / Active Storage setup](#rails--active-storage-setup)
|
|
39
|
+
6. [Standalone S3 usage](#standalone-s3-usage)
|
|
40
|
+
7. [Chunked multipart upload](#chunked-multipart-upload)
|
|
41
|
+
8. [Streaming download](#streaming-download)
|
|
42
|
+
9. [Manual encrypt / decrypt](#manual-encrypt--decrypt)
|
|
43
|
+
10. [Blob metadata](#blob-metadata)
|
|
44
|
+
11. [KMS providers](#kms-providers)
|
|
45
|
+
- [Environment-variable provider](#environment-variable-provider)
|
|
46
|
+
- [AWS KMS provider](#aws-kms-provider)
|
|
47
|
+
- [Custom provider](#custom-provider)
|
|
48
|
+
12. [Configuration reference](#configuration-reference)
|
|
49
|
+
13. [Encryption format](#encryption-format)
|
|
50
|
+
14. [Security notes](#security-notes)
|
|
51
|
+
15. [Testing](#testing)
|
|
52
|
+
16. [Contributing](#contributing)
|
|
53
|
+
17. [Security reports](#security-reports)
|
|
54
|
+
18. [License](#license)
|
|
55
|
+
19. [Ruby and Rails compatibility](#ruby-and-rails-compatibility)
|
|
36
56
|
|
|
37
57
|
## How it works
|
|
38
58
|
|
|
39
|
-
Every
|
|
59
|
+
Every file gets its own random data encryption key. The file is encrypted with AES-256-GCM, and that data key is wrapped by your configured key provider.
|
|
40
60
|
|
|
41
|
-
|
|
42
|
-
┌─────────────────────────────────────────────────────────┐
|
|
43
|
-
│ Plaintext file │
|
|
44
|
-
└────────────────────────┬────────────────────────────────┘
|
|
45
|
-
│
|
|
46
|
-
┌──────────────▼──────────────┐
|
|
47
|
-
│ 1. Generate random DEK │ (32 bytes, AES-256)
|
|
48
|
-
│ per-file, per-operation │
|
|
49
|
-
└──────────────┬──────────────┘
|
|
50
|
-
│
|
|
51
|
-
┌──────────────▼──────────────┐
|
|
52
|
-
│ 2. Encrypt file with DEK │ AES-256-GCM
|
|
53
|
-
│ unique IV per operation │ + auth tag
|
|
54
|
-
└──────────────┬──────────────┘
|
|
55
|
-
│
|
|
56
|
-
┌──────────────▼──────────────┐
|
|
57
|
-
│ 3. Wrap DEK with KMS │ ENV, AWS KMS,
|
|
58
|
-
│ master key │ or custom
|
|
59
|
-
└──────────────┬──────────────┘
|
|
60
|
-
│
|
|
61
|
-
┌──────────────▼──────────────┐
|
|
62
|
-
│ 4. Binary payload │ Header + IV +
|
|
63
|
-
│ (stored in S3) │ Ciphertext + Auth tag
|
|
64
|
-
└─────────────────────────────┘
|
|
65
|
-
```
|
|
61
|
+
The encrypted file is self-contained. It stores:
|
|
66
62
|
|
|
67
|
-
|
|
63
|
+
- a small Active Cipher Storage header,
|
|
64
|
+
- the encrypted data key,
|
|
65
|
+
- the ciphertext,
|
|
66
|
+
- authentication tags used to detect tampering.
|
|
68
67
|
|
|
69
|
-
|
|
68
|
+
When the file is downloaded, the gem reads the header, asks the key provider to unwrap the data key, verifies the AES-GCM authentication tag, and returns plaintext to your app.
|
|
69
|
+
|
|
70
|
+
The same format is used for Rails Active Storage uploads, direct S3 uploads, streaming downloads, and multipart upload flows.
|
|
70
71
|
|
|
71
72
|
## Installation
|
|
72
73
|
|
|
74
|
+
Add the gem to your Gemfile:
|
|
75
|
+
|
|
73
76
|
```ruby
|
|
74
|
-
# Gemfile
|
|
75
77
|
gem "active_cipher_storage"
|
|
78
|
+
```
|
|
76
79
|
|
|
77
|
-
|
|
78
|
-
gem "aws-sdk-kms"
|
|
80
|
+
If you use AWS KMS or the direct S3 adapter, add the AWS SDK gems you need:
|
|
79
81
|
|
|
80
|
-
|
|
82
|
+
```ruby
|
|
83
|
+
gem "aws-sdk-kms"
|
|
81
84
|
gem "aws-sdk-s3"
|
|
82
85
|
```
|
|
83
86
|
|
|
@@ -87,28 +90,36 @@ bundle install
|
|
|
87
90
|
|
|
88
91
|
## Rails / Active Storage setup
|
|
89
92
|
|
|
90
|
-
|
|
93
|
+
Use this path when you want Rails Active Storage to encrypt attachments automatically.
|
|
94
|
+
|
|
95
|
+
Your model, controller, and view code can keep using normal Active Storage APIs. The only change is the storage service configuration.
|
|
96
|
+
|
|
97
|
+
### 1. Configure a key provider
|
|
91
98
|
|
|
92
99
|
```ruby
|
|
93
100
|
# config/initializers/active_cipher_storage.rb
|
|
94
101
|
ActiveCipherStorage.configure do |config|
|
|
95
|
-
#
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
# Built-ins: :env, :aws_kms, "env", or "aws:kms". Custom: pass a Providers::Base instance.
|
|
103
|
+
config.provider = :env
|
|
104
|
+
config.provider_options[:encryption_key] = ENV.fetch("ACTIVE_CIPHER_MASTER_KEY")
|
|
105
|
+
# KMS-only apps often use:
|
|
106
|
+
# config.provider = "aws:kms"
|
|
107
|
+
|
|
108
|
+
# Provider-specific keyword args (see EnvProvider / AwsKmsProvider).
|
|
109
|
+
# config.provider_options[:key_id] = Rails.application.credentials.dig(:aws, :kms_key_id)
|
|
110
|
+
# config.provider_options[:region] = "us-east-1"
|
|
111
|
+
# config.provider_options[:endpoint] = "http://localhost:4566" # e.g. LocalStack
|
|
112
|
+
# config.provider_options[:access_key_id] = "..."
|
|
113
|
+
# config.provider_options[:secret_access_key] = "..."
|
|
114
|
+
# config.provider_options[:encryption_context] = { "app" => "my-app" }
|
|
105
115
|
|
|
106
116
|
# Tuning (optional)
|
|
107
|
-
config.chunk_size = 5 * 1024 * 1024 # 5 MiB per chunk (default)
|
|
108
117
|
config.encrypt_uploads = true # set false to store new Active Storage uploads as plaintext
|
|
109
118
|
end
|
|
110
119
|
```
|
|
111
120
|
|
|
121
|
+
Streaming / multipart plaintext chunk size is **not** global: set `chunk_size` on the **`ActiveCipherStorage`** service in `storage.yml`, and pass `chunk_size` / `multipart_threshold` into **`S3Adapter`** / **`EncryptedMultipartUpload`** when you construct them (default **5 MiB**, `ActiveCipherStorage::Configuration::DEFAULT_CHUNK_SIZE`).
|
|
122
|
+
|
|
112
123
|
Generate a master key for local development:
|
|
113
124
|
|
|
114
125
|
```bash
|
|
@@ -128,8 +139,9 @@ ACTIVE_CIPHER_MASTER_KEY=<base64-encoded-key>
|
|
|
128
139
|
# config/storage.yml
|
|
129
140
|
|
|
130
141
|
encrypted_s3:
|
|
131
|
-
service: ActiveCipherStorage #
|
|
142
|
+
service: ActiveCipherStorage # resolves to ActiveStorage::Service::ActiveCipherStorageService
|
|
132
143
|
wrapped_service: s3 # name of another service in this file
|
|
144
|
+
chunk_size: 6291456 # plaintext bytes per stream chunk (>= 5 MiB for S3 multipart)
|
|
133
145
|
|
|
134
146
|
s3:
|
|
135
147
|
service: S3
|
|
@@ -154,7 +166,9 @@ user.document.attach(io: file, filename: "report.pdf")
|
|
|
154
166
|
url = rails_blob_url(user.document)
|
|
155
167
|
```
|
|
156
168
|
|
|
157
|
-
Active Storage
|
|
169
|
+
Active Storage now encrypts on upload and decrypts on download.
|
|
170
|
+
|
|
171
|
+
Existing plaintext objects are still readable. If a blob does not start with the `ACS\x01` magic header, the service returns it unchanged.
|
|
158
172
|
|
|
159
173
|
`config.encrypt_uploads` controls new Active Storage writes only. When disabled, new uploads are stored as plaintext and marked with `"encrypted": false` metadata. Reads continue to auto-detect by payload header, so existing encrypted blobs still decrypt correctly and existing plaintext blobs still download unchanged.
|
|
160
174
|
|
|
@@ -162,26 +176,30 @@ Direct Active Storage browser uploads are intentionally disabled because they by
|
|
|
162
176
|
|
|
163
177
|
## Standalone S3 usage
|
|
164
178
|
|
|
165
|
-
|
|
179
|
+
You can also use Active Cipher Storage without Rails.
|
|
180
|
+
|
|
181
|
+
This is useful for background jobs, service objects, scripts, or non-Rails Ruby apps that upload encrypted files directly to S3.
|
|
166
182
|
|
|
167
183
|
```ruby
|
|
168
184
|
require "active_cipher_storage"
|
|
169
185
|
|
|
170
186
|
ActiveCipherStorage.configure do |c|
|
|
171
|
-
c.provider =
|
|
187
|
+
c.provider = :env
|
|
188
|
+
c.provider_options[:encryption_key] = ENV.fetch("ACTIVE_CIPHER_MASTER_KEY")
|
|
172
189
|
end
|
|
173
190
|
|
|
174
191
|
s3 = ActiveCipherStorage::Adapters::S3Adapter.new(
|
|
175
|
-
bucket:
|
|
176
|
-
region:
|
|
192
|
+
bucket: "my-bucket",
|
|
193
|
+
region: "us-east-1",
|
|
194
|
+
chunk_size: ActiveCipherStorage::Configuration::DEFAULT_CHUNK_SIZE
|
|
177
195
|
)
|
|
178
196
|
|
|
179
|
-
# Encrypt
|
|
197
|
+
# Encrypt before upload
|
|
180
198
|
File.open("contract.pdf", "rb") do |f|
|
|
181
199
|
s3.put_encrypted("legal/contract-2026.pdf", f)
|
|
182
200
|
end
|
|
183
201
|
|
|
184
|
-
# Download and decrypt
|
|
202
|
+
# Download and decrypt
|
|
185
203
|
io = s3.get_decrypted("legal/contract-2026.pdf")
|
|
186
204
|
File.binwrite("decrypted_contract.pdf", io.read)
|
|
187
205
|
```
|
|
@@ -191,30 +209,36 @@ Large files are automatically uploaded via S3 multipart when the payload exceeds
|
|
|
191
209
|
```ruby
|
|
192
210
|
s3 = ActiveCipherStorage::Adapters::S3Adapter.new(
|
|
193
211
|
bucket: "my-bucket",
|
|
194
|
-
multipart_threshold: 50 * 1024 * 1024 # 50 MiB
|
|
212
|
+
multipart_threshold: 50 * 1024 * 1024, # 50 MiB
|
|
213
|
+
chunk_size: 5 * 1024 * 1024
|
|
195
214
|
)
|
|
196
215
|
```
|
|
197
216
|
|
|
198
217
|
## Chunked multipart upload
|
|
199
218
|
|
|
200
|
-
For large files
|
|
219
|
+
For large files, many apps upload from the browser in chunks.
|
|
220
|
+
|
|
221
|
+
Active Cipher Storage supports that flow, but the browser still does not get encryption keys. The frontend sends plaintext chunks to your Rails app, and your backend encrypts those chunks before uploading encrypted multipart parts to S3.
|
|
201
222
|
|
|
202
|
-
|
|
223
|
+
Use `EncryptedMultipartUpload` for this backend-managed upload flow.
|
|
224
|
+
|
|
225
|
+
**Memory behavior:** the app does not assemble the whole file before uploading. Each `upload_part` call reads the incoming frontend chunk, encrypts that chunk into an authenticated ACS frame, and flushes encrypted bytes to S3 multipart upload parts as soon as the S3 minimum part size is reached. Keep frontend chunks bounded (for example 256 KiB to 5 MiB); a single frontend chunk is read into memory for that request.
|
|
203
226
|
|
|
204
227
|
```ruby
|
|
205
228
|
uploader = ActiveCipherStorage::EncryptedMultipartUpload.new(
|
|
206
|
-
s3_client:
|
|
207
|
-
bucket:
|
|
229
|
+
s3_client: Aws::S3::Client.new(region: "us-east-1"),
|
|
230
|
+
bucket: "my-bucket",
|
|
231
|
+
chunk_size: 5 * 1024 * 1024
|
|
208
232
|
)
|
|
209
233
|
|
|
210
|
-
#
|
|
234
|
+
# Request 1: start the upload
|
|
211
235
|
session_id = uploader.initiate(key: "uploads/video.mp4")
|
|
212
236
|
# Keep session_id for this active upload lifecycle.
|
|
213
237
|
|
|
214
|
-
#
|
|
238
|
+
# Requests 2..N: send chunks
|
|
215
239
|
uploader.upload_part(session_id: session_id, chunk_io: request.body)
|
|
216
240
|
|
|
217
|
-
#
|
|
241
|
+
# Final request: seal and complete
|
|
218
242
|
result = uploader.complete(session_id: session_id)
|
|
219
243
|
# => { status: :completed, key: "uploads/video.mp4", parts_count: 12 }
|
|
220
244
|
```
|
|
@@ -248,37 +272,45 @@ class UploadsController < ApplicationController
|
|
|
248
272
|
|
|
249
273
|
def set_uploader
|
|
250
274
|
@uploader = ActiveCipherStorage::EncryptedMultipartUpload.new(
|
|
251
|
-
s3_client:
|
|
252
|
-
bucket:
|
|
275
|
+
s3_client: s3_client,
|
|
276
|
+
bucket: ENV.fetch("S3_BUCKET"),
|
|
277
|
+
chunk_size: 5 * 1024 * 1024
|
|
253
278
|
)
|
|
254
279
|
end
|
|
255
280
|
end
|
|
256
281
|
```
|
|
257
282
|
|
|
258
|
-
**Session storage
|
|
283
|
+
**Session storage**
|
|
284
|
+
|
|
259
285
|
By default, session state is held in process memory (`MemorySessionStore`). This is intended for one active backend-managed upload lifecycle and is not durable across process restarts or deploys.
|
|
260
286
|
|
|
261
287
|
For multi-process deployments where chunks for the same active upload may land on different workers or hosts, pass a shared store:
|
|
262
288
|
|
|
263
289
|
```ruby
|
|
264
|
-
# Rails.cache backed by Redis
|
|
290
|
+
# Rails.cache backed by Redis allows cross-worker active upload sessions.
|
|
265
291
|
uploader = ActiveCipherStorage::EncryptedMultipartUpload.new(
|
|
266
|
-
s3_client:
|
|
267
|
-
bucket:
|
|
268
|
-
|
|
292
|
+
s3_client: s3_client,
|
|
293
|
+
bucket: "my-bucket",
|
|
294
|
+
chunk_size: 5 * 1024 * 1024,
|
|
295
|
+
store: Rails.cache # any object with read/write/delete
|
|
269
296
|
)
|
|
270
297
|
```
|
|
271
298
|
|
|
272
|
-
**Security:** The plaintext
|
|
299
|
+
**Security:** The plaintext data key is never stored in the session. Only the KMS-wrapped encrypted data key is persisted; it is decrypted fresh for each chunk and zeroed immediately after use.
|
|
273
300
|
|
|
274
301
|
## Streaming download
|
|
275
302
|
|
|
276
|
-
`stream_decrypted`
|
|
303
|
+
Use `stream_decrypted` when you need to send a large encrypted file to a client without loading the whole file into memory.
|
|
304
|
+
|
|
305
|
+
The adapter reads encrypted bytes from S3, decrypts authenticated chunks as they arrive, and yields plaintext chunks to your block. Memory usage stays bounded by one Active Cipher Storage chunk, which is 5 MiB by default.
|
|
306
|
+
|
|
307
|
+
Use `stream_decrypted` for huge files. `get_decrypted` returns an IO for convenience, but it buffers the encrypted object before decrypting and is intended for small objects or tooling, not large client downloads.
|
|
277
308
|
|
|
278
309
|
```ruby
|
|
279
310
|
s3 = ActiveCipherStorage::Adapters::S3Adapter.new(
|
|
280
|
-
bucket:
|
|
281
|
-
region:
|
|
311
|
+
bucket: "my-bucket",
|
|
312
|
+
region: "us-east-1",
|
|
313
|
+
chunk_size: ActiveCipherStorage::Configuration::DEFAULT_CHUNK_SIZE
|
|
282
314
|
)
|
|
283
315
|
|
|
284
316
|
# Stream directly into a Rails response
|
|
@@ -302,22 +334,25 @@ File.open("output.bin", "wb") do |f|
|
|
|
302
334
|
end
|
|
303
335
|
```
|
|
304
336
|
|
|
305
|
-
`stream_decrypted` handles S3 delivering data in any chunk size
|
|
337
|
+
`stream_decrypted` handles S3 delivering data in any chunk size. The internal decryptor buffers incoming bytes and emits plaintext only when a complete, authenticated frame is available.
|
|
306
338
|
|
|
307
339
|
Use `stream_decrypted` for chunked ACS objects. If the object is non-chunked, call `get_decrypted`; streaming a non-chunked or non-ACS/plaintext object raises `InvalidFormat` with a clear error.
|
|
308
340
|
|
|
309
341
|
## Manual encrypt / decrypt
|
|
310
342
|
|
|
311
|
-
|
|
343
|
+
If you do not need Rails or S3 integration, you can use the lower-level cipher classes directly.
|
|
344
|
+
|
|
345
|
+
Use `Cipher` for small files and `StreamCipher` for large files:
|
|
312
346
|
|
|
313
347
|
```ruby
|
|
314
348
|
require "active_cipher_storage"
|
|
315
349
|
|
|
316
350
|
ActiveCipherStorage.configure do |c|
|
|
317
|
-
c.provider =
|
|
351
|
+
c.provider = :env
|
|
352
|
+
c.provider_options[:encryption_key] = ENV.fetch("ACTIVE_CIPHER_MASTER_KEY")
|
|
318
353
|
end
|
|
319
354
|
|
|
320
|
-
#
|
|
355
|
+
# Small files
|
|
321
356
|
cipher = ActiveCipherStorage::Cipher.new
|
|
322
357
|
encrypted = cipher.encrypt(File.open("secret.txt", "rb"))
|
|
323
358
|
# => Binary String with embedded header, IV, ciphertext, auth tag
|
|
@@ -325,7 +360,7 @@ encrypted = cipher.encrypt(File.open("secret.txt", "rb"))
|
|
|
325
360
|
plaintext = cipher.decrypt(encrypted)
|
|
326
361
|
# => Original plaintext String
|
|
327
362
|
|
|
328
|
-
#
|
|
363
|
+
# Large files
|
|
329
364
|
stream = ActiveCipherStorage::StreamCipher.new
|
|
330
365
|
|
|
331
366
|
File.open("large.bin", "rb") do |input|
|
|
@@ -354,71 +389,74 @@ When using the Rails Active Storage adapter, encryption metadata is automaticall
|
|
|
354
389
|
}
|
|
355
390
|
```
|
|
356
391
|
|
|
357
|
-
This metadata
|
|
392
|
+
This metadata supports:
|
|
358
393
|
|
|
359
|
-
- **Key rotation queries** — find every blob encrypted under a given KMS key without scanning blob bodies
|
|
360
394
|
- **Backward compatibility** — blobs uploaded before encryption was enabled are detected by the absence of the `ACS\x01` magic header and served as raw bytes
|
|
361
|
-
- **Operational auditing** —
|
|
395
|
+
- **Operational auditing** — see which provider and key identifier were used when the blob was stored
|
|
362
396
|
|
|
363
397
|
The binary file header remains the ground truth for decryption; metadata is informational only and a mismatch does not affect correctness.
|
|
364
398
|
|
|
365
|
-
**
|
|
399
|
+
Changing KMS keys or providers for existing blobs is **not** handled by this gem: implement your own migration (for example using AWS KMS `ReEncrypt`, custom jobs, or by reading/writing raw storage keys via your Active Storage service’s **`download_raw`** / **`upload_raw`** if you need byte-level access). The [Encryption format](#encryption-format) section documents the on-disk layout.
|
|
366
400
|
|
|
367
|
-
|
|
368
|
-
svc = ActiveCipherStorage::Adapters::ActiveStorageService.new(wrapped_service: inner)
|
|
401
|
+
## KMS providers
|
|
369
402
|
|
|
370
|
-
|
|
371
|
-
"storage/key/for/blob",
|
|
372
|
-
old_provider: old_provider,
|
|
373
|
-
new_provider: new_provider
|
|
374
|
-
)
|
|
375
|
-
# => { status: :rotated }
|
|
376
|
-
```
|
|
403
|
+
### Environment-variable provider
|
|
377
404
|
|
|
378
|
-
**
|
|
405
|
+
**Configure block (recommended)**
|
|
379
406
|
|
|
380
407
|
```ruby
|
|
381
|
-
ActiveCipherStorage
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
service: MyEncryptedStorageService.new
|
|
385
|
-
) do |blob, result|
|
|
386
|
-
Rails.logger.info "#{blob.key}: #{result[:status]}"
|
|
408
|
+
ActiveCipherStorage.configure do |config|
|
|
409
|
+
config.provider = :env
|
|
410
|
+
config.provider_options[:encryption_key] = ENV.fetch("ACTIVE_CIPHER_MASTER_KEY")
|
|
387
411
|
end
|
|
388
412
|
```
|
|
389
413
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
## KMS providers
|
|
393
|
-
|
|
394
|
-
### Environment-variable provider
|
|
414
|
+
**Manual constructor** (tests, advanced use)
|
|
395
415
|
|
|
396
416
|
```ruby
|
|
397
|
-
# Default env var: ACTIVE_CIPHER_MASTER_KEY
|
|
398
|
-
provider = ActiveCipherStorage::Providers::EnvProvider.new
|
|
399
|
-
|
|
400
|
-
# Custom env var name
|
|
401
417
|
provider = ActiveCipherStorage::Providers::EnvProvider.new(
|
|
402
|
-
|
|
418
|
+
encryption_key: ENV.fetch("ACTIVE_CIPHER_MASTER_KEY")
|
|
403
419
|
)
|
|
404
420
|
```
|
|
405
421
|
|
|
406
|
-
The master key wraps each per-file DEK with AES-256-GCM.
|
|
422
|
+
The encryption key is a Base64-encoded 32-byte master key. It wraps each per-file DEK with AES-256-GCM. The wrapped DEK is stored in the file header; the plaintext DEK exists only during the encrypt/decrypt operation.
|
|
407
423
|
|
|
408
424
|
### AWS KMS provider
|
|
409
425
|
|
|
426
|
+
The gem builds `Aws::KMS::Client` inside `AwsKmsProvider`. Pass settings via `ActiveCipherStorage.configure` (recommended) or construct the provider directly.
|
|
427
|
+
|
|
428
|
+
**Configure block (recommended)**
|
|
429
|
+
|
|
430
|
+
```ruby
|
|
431
|
+
ActiveCipherStorage.configure do |config|
|
|
432
|
+
config.provider = :aws_kms # or "aws:kms"
|
|
433
|
+
|
|
434
|
+
config.provider_options[:key_id] = "arn:aws:kms:us-east-1:123456789:key/mrk-abc123"
|
|
435
|
+
config.provider_options[:region] = "us-east-1"
|
|
436
|
+
# Optional — LocalStack or static keys when not using the default credential chain:
|
|
437
|
+
# config.provider_options[:endpoint] = "http://127.0.0.1:4566"
|
|
438
|
+
# config.provider_options[:access_key_id] = "test"
|
|
439
|
+
# config.provider_options[:secret_access_key] = "test"
|
|
440
|
+
# config.provider_options[:encryption_context] = { "app" => "my-app", "env" => Rails.env }
|
|
441
|
+
end
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Set `provider_options` from your app (e.g. `Rails.application.credentials`, `ENV`, etc.). The gem forwards them as keyword arguments to **`AwsKmsProvider`**.
|
|
445
|
+
|
|
446
|
+
**Manual constructor** (tests, advanced use)
|
|
447
|
+
|
|
410
448
|
```ruby
|
|
411
449
|
provider = ActiveCipherStorage::Providers::AwsKmsProvider.new(
|
|
412
450
|
key_id: "arn:aws:kms:us-east-1:123456789:key/mrk-abc123",
|
|
413
451
|
region: "us-east-1",
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
452
|
+
endpoint: "http://127.0.0.1:4566",
|
|
453
|
+
access_key_id: "test",
|
|
454
|
+
secret_access_key: "test",
|
|
417
455
|
encryption_context: { "app" => "my-app", "env" => Rails.env }
|
|
418
456
|
)
|
|
419
457
|
```
|
|
420
458
|
|
|
421
|
-
|
|
459
|
+
You can still pass `client:` to inject a custom `Aws::KMS::Client` (e.g. in tests).
|
|
422
460
|
|
|
423
461
|
### Custom provider
|
|
424
462
|
|
|
@@ -440,10 +478,6 @@ class MyVaultProvider < ActiveCipherStorage::Providers::Base
|
|
|
440
478
|
vault_client.decrypt(encrypted_key)
|
|
441
479
|
end
|
|
442
480
|
|
|
443
|
-
def wrap_data_key(plaintext_dek)
|
|
444
|
-
vault_client.encrypt(plaintext_dek)
|
|
445
|
-
end
|
|
446
|
-
|
|
447
481
|
private
|
|
448
482
|
|
|
449
483
|
def vault_client
|
|
@@ -458,59 +492,23 @@ end
|
|
|
458
492
|
|
|
459
493
|
The `provider_id` is embedded in every encrypted file. Routing at decrypt time is handled by whichever provider is configured — it is the application's responsibility to configure the right provider for each environment.
|
|
460
494
|
|
|
461
|
-
Implement `rotate_data_key(encrypted_key)` as well if the provider can re-wrap encrypted DEKs without exposing plaintext key material.
|
|
462
|
-
|
|
463
|
-
## Key rotation
|
|
464
|
-
|
|
465
|
-
### AWS KMS automatic rotation
|
|
466
|
-
|
|
467
|
-
Enable automatic key rotation on the CMK in the AWS Console or via CLI. AWS transparently re-wraps all data keys on the next use — no application changes needed.
|
|
468
|
-
|
|
469
|
-
### Cross-key and cross-provider rotation
|
|
470
|
-
|
|
471
|
-
Use `KeyRotation.rotate` (covered in [Blob metadata](#blob-metadata)) to batch re-wrap all blobs under a new key. For AWS KMS → AWS KMS rotations the plaintext DEK never leaves KMS (`ReEncrypt` API). Cross-provider rotations (e.g. `EnvProvider` → `AwsKmsProvider`) briefly hold the plaintext DEK in process memory and zero it immediately after.
|
|
472
|
-
|
|
473
|
-
**Dry-run mode** — validate headers without uploading:
|
|
474
|
-
|
|
475
|
-
```ruby
|
|
476
|
-
ActiveCipherStorage::KeyRotation.rotate(
|
|
477
|
-
old_provider: old_kms,
|
|
478
|
-
new_provider: new_kms,
|
|
479
|
-
service: svc,
|
|
480
|
-
dry_run: true
|
|
481
|
-
) do |blob, result|
|
|
482
|
-
puts "#{blob.key}: #{result[:status]}" # :validated or :failed
|
|
483
|
-
end
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
### Low-level DEK re-wrapping
|
|
487
|
-
|
|
488
|
-
```ruby
|
|
489
|
-
# AWS KMS → AWS KMS (ReEncrypt, no plaintext in memory)
|
|
490
|
-
old_provider = ActiveCipherStorage::Providers::AwsKmsProvider.new(key_id: "arn:...old")
|
|
491
|
-
new_provider = ActiveCipherStorage::Providers::AwsKmsProvider.new(key_id: "arn:...new")
|
|
492
|
-
new_dek = old_provider.rotate_data_key(encrypted_dek, destination_key_id: new_provider.key_id)
|
|
493
|
-
|
|
494
|
-
# EnvProvider → EnvProvider
|
|
495
|
-
old_provider = ActiveCipherStorage::Providers::EnvProvider.new(env_var: "OLD_KEY")
|
|
496
|
-
new_provider = ActiveCipherStorage::Providers::EnvProvider.new(env_var: "NEW_KEY")
|
|
497
|
-
new_dek = new_provider.rotate_data_key(encrypted_dek, old_provider: old_provider)
|
|
498
|
-
```
|
|
499
|
-
|
|
500
495
|
## Configuration reference
|
|
501
496
|
|
|
502
497
|
```ruby
|
|
503
498
|
ActiveCipherStorage.configure do |config|
|
|
504
|
-
# Required.
|
|
499
|
+
# Required. Providers::Base instance, or :env / :aws_kms / "env" / "aws:kms".
|
|
505
500
|
config.provider = :env
|
|
506
501
|
|
|
502
|
+
# Keyword options forwarded to the built-in provider’s `.new` (see KMS providers).
|
|
503
|
+
# config.provider_options[:encryption_key] = ENV.fetch("ACTIVE_CIPHER_MASTER_KEY")
|
|
504
|
+
# config.provider_options[:key_id] = "..."
|
|
505
|
+
# config.provider_options[:region] = "us-east-1"
|
|
506
|
+
# config.provider_options[:endpoint] = nil
|
|
507
|
+
# config.provider_options[:encryption_context] = {}
|
|
508
|
+
|
|
507
509
|
# Encryption algorithm. Currently only "aes-256-gcm" is supported.
|
|
508
510
|
config.algorithm = "aes-256-gcm"
|
|
509
511
|
|
|
510
|
-
# Plaintext bytes per chunk in StreamCipher mode.
|
|
511
|
-
# Must be >= 5 MiB for S3 multipart uploads (except the last part).
|
|
512
|
-
config.chunk_size = 5 * 1024 * 1024
|
|
513
|
-
|
|
514
512
|
# Controls new Active Storage uploads only. Downloads always auto-detect
|
|
515
513
|
# encrypted vs. plaintext payloads by the ACS header.
|
|
516
514
|
config.encrypt_uploads = true
|
|
@@ -585,7 +583,7 @@ Integration tests use in-memory fakes for both Active Storage and S3 — no real
|
|
|
585
583
|
|
|
586
584
|
Contributions are welcome. Please read `CONTRIBUTING.md` before opening a pull request.
|
|
587
585
|
|
|
588
|
-
For changes that affect encryption, streaming, providers,
|
|
586
|
+
For changes that affect encryption, streaming, providers, or storage behavior, include focused specs that prove both the success path and the failure/tamper path. Run the full suite before submitting:
|
|
589
587
|
|
|
590
588
|
```bash
|
|
591
589
|
bundle exec rspec
|