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.
data/README.md CHANGED
@@ -1,83 +1,86 @@
1
- # ActiveCipherStorage
1
+ # Active Cipher Storage
2
2
 
3
3
  [![CI](https://github.com/codebyjass/active-cipher-storage/actions/workflows/ruby.yml/badge.svg)](https://github.com/codebyjass/active-cipher-storage/actions/workflows/ruby.yml)
4
4
 
5
- ActiveCipherStorage is a Ruby gem for Rails Active Storage encryption and decryption. It encrypts files before they are stored, decrypts them when they are read, and supports AWS S3, streaming downloads, multipart uploads, AES-256-GCM envelope encryption, AWS KMS, and custom key providers.
5
+ Active Cipher Storage is published as the `active_cipher_storage` Ruby gem.
6
6
 
7
- ActiveCipherStorage supports three upload paths:
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
- - **Rails Active Storage** application code keeps using normal attachment APIs while the storage service encrypts on upload and decrypts on download.
10
- - **Direct S3 clients** — service objects and non-Rails apps can call `put_encrypted`, `get_decrypted`, and `stream_decrypted`.
11
- - **Frontend chunk uploads** the frontend sends plaintext chunks to your backend; the backend encrypts those chunks and uploads encrypted S3 multipart parts.
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. [How it works](#how-it-works)
16
- 2. [Installation](#installation)
17
- 3. [Rails / Active Storage setup](#rails--active-storage-setup)
18
- 4. [Standalone S3 usage](#standalone-s3-usage)
19
- 5. [Chunked multipart upload](#chunked-multipart-upload)
20
- 6. [Streaming download](#streaming-download)
21
- 7. [Manual encrypt / decrypt](#manual-encrypt--decrypt)
22
- 8. [Blob metadata](#blob-metadata)
23
- 9. [KMS providers](#kms-providers)
24
- - [Environment-variable provider](#environment-variable-provider)
25
- - [AWS KMS provider](#aws-kms-provider)
26
- - [Custom provider](#custom-provider)
27
- 10. [Key rotation](#key-rotation)
28
- 11. [Configuration reference](#configuration-reference)
29
- 12. [Encryption format](#encryption-format)
30
- 13. [Security notes](#security-notes)
31
- 14. [Testing](#testing)
32
- 15. [Contributing](#contributing)
33
- 16. [Security reports](#security-reports)
34
- 17. [License](#license)
35
- 18. [Ruby and Rails compatibility](#ruby-and-rails-compatibility)
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 encrypted file is self-contained. No external metadata store is needed.
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
- Decryption reverses the flow: the KMS provider unwraps the DEK from the header, then AES-GCM verifies the auth tag and decrypts the ciphertext.
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
- Every encrypted payload uses the same self-describing format, whether it came from Active Storage, the direct S3 adapter, or the backend chunk upload API.
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
- # For AWS KMS provider:
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
- # For standalone S3 adapter:
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
- ### 1. Configure a KMS provider
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
- # Choose one provider:
96
-
97
- # Option A — environment variable (development / staging)
98
- config.provider = :env # reads ACTIVE_CIPHER_MASTER_KEY
99
-
100
- # Option B — AWS KMS (production)
101
- config.provider = ActiveCipherStorage::Providers::AwsKmsProvider.new(
102
- key_id: Rails.application.credentials.dig(:aws, :kms_key_id),
103
- region: "us-east-1"
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 # resolved by the Engine
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 transparently encrypts on upload and decrypts on download. Existing plaintext objects are still readable: if a blob does not start with the `ACS\x01` magic header, the service returns it unchanged.
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
- No Rails required.
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 = ActiveCipherStorage::Providers::EnvProvider.new
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: "my-bucket",
176
- region: "us-east-1"
192
+ bucket: "my-bucket",
193
+ region: "us-east-1",
194
+ chunk_size: ActiveCipherStorage::Configuration::DEFAULT_CHUNK_SIZE
177
195
  )
178
196
 
179
- # Encrypt and upload
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 — returns an IO
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 where the frontend sends data in separate HTTP requests, use `EncryptedMultipartUpload`. Each frontend chunk is encrypted by the backend as an authenticated ACS frame and buffered until the S3 multipart minimum part size is met, then flushed as an encrypted S3 multipart part.
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
- This flow is backend-managed. The frontend never receives encryption keys and never uploads plaintext directly to S3.
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: Aws::S3::Client.new(region: "us-east-1"),
207
- bucket: "my-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
- # --- Request 1: start the upload ---
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
- # --- Requests 2..N: send chunks (any size) ---
238
+ # Requests 2..N: send chunks
215
239
  uploader.upload_part(session_id: session_id, chunk_io: request.body)
216
240
 
217
- # --- Final request: seal and complete ---
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: s3_client,
252
- bucket: ENV.fetch("S3_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 allows cross-worker active upload sessions
290
+ # Rails.cache backed by Redis allows cross-worker active upload sessions.
265
291
  uploader = ActiveCipherStorage::EncryptedMultipartUpload.new(
266
- s3_client: s3_client,
267
- bucket: "my-bucket",
268
- store: Rails.cache # any object with read/write/delete
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 DEK is never stored in the session. Only the KMS-wrapped encrypted DEK is persisted; it is decrypted fresh for each chunk and zeroed immediately after use.
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` pipes S3 bytes through the decryptor and yields plaintext chunks on the fly. Memory usage is bounded by one ACS chunk (default 5 MiB) regardless of file size.
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: "my-bucket",
281
- region: "us-east-1"
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 the internal `StreamingDecryptor` buffers incoming bytes and emits plaintext only when a complete, authenticated ACS frame is available.
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
- Use `Cipher` (in-memory) or `StreamCipher` (chunked, constant memory):
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 = ActiveCipherStorage::Providers::EnvProvider.new
351
+ c.provider = :env
352
+ c.provider_options[:encryption_key] = ENV.fetch("ACTIVE_CIPHER_MASTER_KEY")
318
353
  end
319
354
 
320
- # ── In-memory (small files) ─────────────────────────────
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
- # ── Streaming (large files) ─────────────────────────────
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 powers:
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** — know which key protects which blobs at a glance
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
- **Single-blob re-key** (re-wrap DEK without touching the file body):
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
- ```ruby
368
- svc = ActiveCipherStorage::Adapters::ActiveStorageService.new(wrapped_service: inner)
401
+ ## KMS providers
369
402
 
370
- result = svc.rekey(
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
- **Batch key rotation** across all blobs for a provider:
405
+ **Configure block (recommended)**
379
406
 
380
407
  ```ruby
381
- ActiveCipherStorage::KeyRotation.rotate(
382
- old_provider: old_kms,
383
- new_provider: new_kms,
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
- Only the encrypted DEK in the file header is rewritten — the IV, ciphertext, and auth tags are copied byte-for-byte. This makes rotation O(header size) in data transferred per file, not O(file size). For AWS KMS → AWS KMS rotations, the plaintext DEK never leaves KMS (uses `ReEncrypt` API).
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
- env_var: "MYAPP_ENCRYPTION_KEY"
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. The wrapped DEK is stored in the file header; the plaintext DEK exists only during the encrypt/decrypt operation.
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
- # Bind the DEK to a specific resource. The same context must be
416
- # present on decrypt — different context = decryption failure.
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
- AWS credentials are resolved through the standard SDK chain (env vars, `~/.aws/credentials`, instance profile, EKS IRSA, etc.).
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. A Providers::Base instance or :env / :aws_kms shorthand.
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, key rotation, or storage behavior, include focused specs that prove both the success path and the failure/tamper path. Run the full suite before submitting:
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