active_cipher_storage 1.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 +7 -0
- data/CHANGELOG.md +24 -0
- data/CONTRIBUTING.md +46 -0
- data/LICENSE +21 -0
- data/README.md +644 -0
- data/SECURITY.md +35 -0
- data/active_cipher_storage.gemspec +55 -0
- data/lib/active_cipher_storage/adapters/active_storage_service.rb +129 -0
- data/lib/active_cipher_storage/adapters/s3_adapter.rb +252 -0
- data/lib/active_cipher_storage/blob_metadata.rb +84 -0
- data/lib/active_cipher_storage/cipher.rb +97 -0
- data/lib/active_cipher_storage/configuration.rb +57 -0
- data/lib/active_cipher_storage/engine.rb +29 -0
- data/lib/active_cipher_storage/errors.rb +31 -0
- data/lib/active_cipher_storage/format.rb +126 -0
- data/lib/active_cipher_storage/key_rotation.rb +121 -0
- data/lib/active_cipher_storage/key_utils.rb +12 -0
- data/lib/active_cipher_storage/multipart_upload.rb +190 -0
- data/lib/active_cipher_storage/providers/aws_kms_provider.rb +90 -0
- data/lib/active_cipher_storage/providers/base.rb +38 -0
- data/lib/active_cipher_storage/providers/env_provider.rb +122 -0
- data/lib/active_cipher_storage/stream_cipher.rb +102 -0
- data/lib/active_cipher_storage/version.rb +3 -0
- data/lib/active_cipher_storage.rb +43 -0
- data/lib/active_storage/service/active_cipher_storage_service.rb +10 -0
- metadata +224 -0
data/README.md
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
# ActiveCipherStorage
|
|
2
|
+
|
|
3
|
+
[](https://github.com/codebyjass/active-cipher-storage/actions/workflows/ruby.yml)
|
|
4
|
+
|
|
5
|
+
Transparent AES-256-GCM encryption for Rails Active Storage, direct AWS S3 usage, and backend-managed chunk uploads, with a pluggable KMS provider layer.
|
|
6
|
+
|
|
7
|
+
ActiveCipherStorage supports three upload paths:
|
|
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.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Contents
|
|
16
|
+
|
|
17
|
+
1. [How it works](#how-it-works)
|
|
18
|
+
2. [Installation](#installation)
|
|
19
|
+
3. [Rails / Active Storage setup](#rails--active-storage-setup)
|
|
20
|
+
4. [Standalone S3 usage](#standalone-s3-usage)
|
|
21
|
+
5. [Chunked multipart upload](#chunked-multipart-upload)
|
|
22
|
+
6. [Streaming download](#streaming-download)
|
|
23
|
+
7. [Manual encrypt / decrypt](#manual-encrypt--decrypt)
|
|
24
|
+
8. [Blob metadata](#blob-metadata)
|
|
25
|
+
9. [KMS providers](#kms-providers)
|
|
26
|
+
- [Environment-variable provider](#environment-variable-provider)
|
|
27
|
+
- [AWS KMS provider](#aws-kms-provider)
|
|
28
|
+
- [Custom provider](#custom-provider)
|
|
29
|
+
10. [Key rotation](#key-rotation)
|
|
30
|
+
11. [Configuration reference](#configuration-reference)
|
|
31
|
+
12. [Encryption format](#encryption-format)
|
|
32
|
+
13. [Security notes](#security-notes)
|
|
33
|
+
14. [Testing](#testing)
|
|
34
|
+
15. [Contributing](#contributing)
|
|
35
|
+
16. [Security reports](#security-reports)
|
|
36
|
+
17. [License](#license)
|
|
37
|
+
18. [Ruby and Rails compatibility](#ruby-and-rails-compatibility)
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## How it works
|
|
42
|
+
|
|
43
|
+
Every encrypted file is self-contained. No external metadata store is needed.
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
┌─────────────────────────────────────────────────────────┐
|
|
47
|
+
│ Plaintext file │
|
|
48
|
+
└────────────────────────┬────────────────────────────────┘
|
|
49
|
+
│
|
|
50
|
+
┌──────────────▼──────────────┐
|
|
51
|
+
│ 1. Generate random DEK │ (32 bytes, AES-256)
|
|
52
|
+
│ per-file, per-operation │
|
|
53
|
+
└──────────────┬──────────────┘
|
|
54
|
+
│
|
|
55
|
+
┌──────────────▼──────────────┐
|
|
56
|
+
│ 2. Encrypt file with DEK │ AES-256-GCM
|
|
57
|
+
│ unique IV per operation │ + auth tag
|
|
58
|
+
└──────────────┬──────────────┘
|
|
59
|
+
│
|
|
60
|
+
┌──────────────▼──────────────┐
|
|
61
|
+
│ 3. Wrap DEK with KMS │ ENV, AWS KMS,
|
|
62
|
+
│ master key │ or custom
|
|
63
|
+
└──────────────┬──────────────┘
|
|
64
|
+
│
|
|
65
|
+
┌──────────────▼──────────────┐
|
|
66
|
+
│ 4. Binary payload │ Header + IV +
|
|
67
|
+
│ (stored in S3) │ Ciphertext + Auth tag
|
|
68
|
+
└─────────────────────────────┘
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Decryption reverses the flow: the KMS provider unwraps the DEK from the header, then AES-GCM verifies the auth tag and decrypts the ciphertext.
|
|
72
|
+
|
|
73
|
+
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.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Installation
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
# Gemfile
|
|
81
|
+
gem "active_cipher_storage"
|
|
82
|
+
|
|
83
|
+
# For AWS KMS provider:
|
|
84
|
+
gem "aws-sdk-kms"
|
|
85
|
+
|
|
86
|
+
# For standalone S3 adapter:
|
|
87
|
+
gem "aws-sdk-s3"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
bundle install
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Rails / Active Storage setup
|
|
97
|
+
|
|
98
|
+
### 1. Configure a KMS provider
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
# config/initializers/active_cipher_storage.rb
|
|
102
|
+
ActiveCipherStorage.configure do |config|
|
|
103
|
+
# Choose one provider:
|
|
104
|
+
|
|
105
|
+
# Option A — environment variable (development / staging)
|
|
106
|
+
config.provider = :env # reads ACTIVE_CIPHER_MASTER_KEY
|
|
107
|
+
|
|
108
|
+
# Option B — AWS KMS (production)
|
|
109
|
+
config.provider = ActiveCipherStorage::Providers::AwsKmsProvider.new(
|
|
110
|
+
key_id: Rails.application.credentials.dig(:aws, :kms_key_id),
|
|
111
|
+
region: "us-east-1"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Tuning (optional)
|
|
115
|
+
config.chunk_size = 5 * 1024 * 1024 # 5 MiB per chunk (default)
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Generate a master key for local development:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
ruby -rsecurerandom -rbase64 \
|
|
123
|
+
-e 'puts Base64.strict_encode64(SecureRandom.bytes(32))'
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Add the output to `.env` (or `config/credentials.yml.enc`):
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
ACTIVE_CIPHER_MASTER_KEY=<base64-encoded-key>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### 2. Add the encrypted service to `config/storage.yml`
|
|
133
|
+
|
|
134
|
+
```yaml
|
|
135
|
+
# config/storage.yml
|
|
136
|
+
|
|
137
|
+
encrypted_s3:
|
|
138
|
+
service: ActiveCipherStorage # resolved by the Engine
|
|
139
|
+
wrapped_service: s3 # name of another service in this file
|
|
140
|
+
|
|
141
|
+
s3:
|
|
142
|
+
service: S3
|
|
143
|
+
access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
|
|
144
|
+
secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
|
|
145
|
+
region: us-east-1
|
|
146
|
+
bucket: my-app-production
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### 3. Attach files using the encrypted service
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
class User < ApplicationRecord
|
|
153
|
+
# All uploads for :document go through encryption automatically.
|
|
154
|
+
has_one_attached :document, service: :encrypted_s3
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
# Controller — no changes needed
|
|
160
|
+
user.document.attach(io: file, filename: "report.pdf")
|
|
161
|
+
url = rails_blob_url(user.document)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
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.
|
|
165
|
+
|
|
166
|
+
Direct Active Storage browser uploads are intentionally disabled because they bypass the backend encryption layer.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Standalone S3 usage
|
|
171
|
+
|
|
172
|
+
No Rails required.
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
require "active_cipher_storage"
|
|
176
|
+
|
|
177
|
+
ActiveCipherStorage.configure do |c|
|
|
178
|
+
c.provider = ActiveCipherStorage::Providers::EnvProvider.new
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
s3 = ActiveCipherStorage::Adapters::S3Adapter.new(
|
|
182
|
+
bucket: "my-bucket",
|
|
183
|
+
region: "us-east-1"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Encrypt and upload
|
|
187
|
+
File.open("contract.pdf", "rb") do |f|
|
|
188
|
+
s3.put_encrypted("legal/contract-2026.pdf", f)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Download and decrypt — returns an IO
|
|
192
|
+
io = s3.get_decrypted("legal/contract-2026.pdf")
|
|
193
|
+
File.binwrite("decrypted_contract.pdf", io.read)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Large files are automatically uploaded via S3 multipart when the payload exceeds `multipart_threshold` (default 100 MiB):
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
s3 = ActiveCipherStorage::Adapters::S3Adapter.new(
|
|
200
|
+
bucket: "my-bucket",
|
|
201
|
+
multipart_threshold: 50 * 1024 * 1024 # 50 MiB
|
|
202
|
+
)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Chunked multipart upload
|
|
208
|
+
|
|
209
|
+
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.
|
|
210
|
+
|
|
211
|
+
This flow is backend-managed. The frontend never receives encryption keys and never uploads plaintext directly to S3.
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
uploader = ActiveCipherStorage::EncryptedMultipartUpload.new(
|
|
215
|
+
s3_client: Aws::S3::Client.new(region: "us-east-1"),
|
|
216
|
+
bucket: "my-bucket"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# --- Request 1: start the upload ---
|
|
220
|
+
session_id = uploader.initiate(key: "uploads/video.mp4")
|
|
221
|
+
# Keep session_id for this active upload lifecycle.
|
|
222
|
+
|
|
223
|
+
# --- Requests 2..N: send chunks (any size) ---
|
|
224
|
+
uploader.upload_part(session_id: session_id, chunk_io: request.body)
|
|
225
|
+
|
|
226
|
+
# --- Final request: seal and complete ---
|
|
227
|
+
result = uploader.complete(session_id: session_id)
|
|
228
|
+
# => { status: :completed, key: "uploads/video.mp4", parts_count: 12 }
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**Rails controller example:**
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
class UploadsController < ApplicationController
|
|
235
|
+
before_action :set_uploader
|
|
236
|
+
|
|
237
|
+
def create
|
|
238
|
+
render json: { session_id: @uploader.initiate(key: upload_key) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def update
|
|
242
|
+
@uploader.upload_part(session_id: params[:session_id], chunk_io: request.body)
|
|
243
|
+
render json: { ok: true }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def complete
|
|
247
|
+
result = @uploader.complete(session_id: params[:session_id])
|
|
248
|
+
render json: result
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def destroy
|
|
252
|
+
@uploader.abort(session_id: params[:session_id])
|
|
253
|
+
head :no_content
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
private
|
|
257
|
+
|
|
258
|
+
def set_uploader
|
|
259
|
+
@uploader = ActiveCipherStorage::EncryptedMultipartUpload.new(
|
|
260
|
+
s3_client: s3_client,
|
|
261
|
+
bucket: ENV.fetch("S3_BUCKET")
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
**Session storage:**
|
|
268
|
+
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.
|
|
269
|
+
|
|
270
|
+
For multi-process deployments where chunks for the same active upload may land on different workers or hosts, pass a shared store:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
# Rails.cache backed by Redis — allows cross-worker active upload sessions
|
|
274
|
+
uploader = ActiveCipherStorage::EncryptedMultipartUpload.new(
|
|
275
|
+
s3_client: s3_client,
|
|
276
|
+
bucket: "my-bucket",
|
|
277
|
+
store: Rails.cache # any object with read/write/delete
|
|
278
|
+
)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
**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.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Streaming download
|
|
286
|
+
|
|
287
|
+
`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.
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
s3 = ActiveCipherStorage::Adapters::S3Adapter.new(
|
|
291
|
+
bucket: "my-bucket",
|
|
292
|
+
region: "us-east-1"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# Stream directly into a Rails response
|
|
296
|
+
def show
|
|
297
|
+
response.headers["Content-Type"] = "application/octet-stream"
|
|
298
|
+
response.headers["Content-Disposition"] = "attachment; filename=\"doc.pdf\""
|
|
299
|
+
response.headers["Transfer-Encoding"] = "chunked"
|
|
300
|
+
|
|
301
|
+
s3.stream_decrypted("uploads/doc.pdf") do |chunk|
|
|
302
|
+
response.stream.write(chunk)
|
|
303
|
+
end
|
|
304
|
+
ensure
|
|
305
|
+
response.stream.close
|
|
306
|
+
end
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
# Stream to a local file
|
|
311
|
+
File.open("output.bin", "wb") do |f|
|
|
312
|
+
s3.stream_decrypted("uploads/large.bin") { |chunk| f.write(chunk) }
|
|
313
|
+
end
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
`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.
|
|
317
|
+
|
|
318
|
+
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.
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Manual encrypt / decrypt
|
|
323
|
+
|
|
324
|
+
Use `Cipher` (in-memory) or `StreamCipher` (chunked, constant memory):
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
require "active_cipher_storage"
|
|
328
|
+
|
|
329
|
+
ActiveCipherStorage.configure do |c|
|
|
330
|
+
c.provider = ActiveCipherStorage::Providers::EnvProvider.new
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# ── In-memory (small files) ─────────────────────────────
|
|
334
|
+
cipher = ActiveCipherStorage::Cipher.new
|
|
335
|
+
encrypted = cipher.encrypt(File.open("secret.txt", "rb"))
|
|
336
|
+
# => Binary String with embedded header, IV, ciphertext, auth tag
|
|
337
|
+
|
|
338
|
+
plaintext = cipher.decrypt(encrypted)
|
|
339
|
+
# => Original plaintext String
|
|
340
|
+
|
|
341
|
+
# ── Streaming (large files) ─────────────────────────────
|
|
342
|
+
stream = ActiveCipherStorage::StreamCipher.new
|
|
343
|
+
|
|
344
|
+
File.open("large.bin", "rb") do |input|
|
|
345
|
+
File.open("large.bin.enc", "wb") do |output|
|
|
346
|
+
stream.encrypt(input, output)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
File.open("large.bin.enc", "rb") do |input|
|
|
351
|
+
File.open("large.bin.dec", "wb") do |output|
|
|
352
|
+
stream.decrypt(input, output)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
## Blob metadata
|
|
360
|
+
|
|
361
|
+
When using the Rails Active Storage adapter, encryption metadata is automatically written to `ActiveStorage::Blob#metadata` after each upload:
|
|
362
|
+
|
|
363
|
+
```json
|
|
364
|
+
{
|
|
365
|
+
"encrypted": true,
|
|
366
|
+
"cipher_version": 1,
|
|
367
|
+
"provider_id": "aws_kms",
|
|
368
|
+
"kms_key_id": "arn:aws:kms:us-east-1:123:key/abc"
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
This metadata powers:
|
|
373
|
+
|
|
374
|
+
- **Key rotation queries** — find every blob encrypted under a given KMS key without scanning blob bodies
|
|
375
|
+
- **Backward compatibility** — blobs uploaded before encryption was enabled are detected by the absence of the `ACS\x01` magic header and served as raw bytes
|
|
376
|
+
- **Operational auditing** — know which key protects which blobs at a glance
|
|
377
|
+
|
|
378
|
+
The binary file header remains the ground truth for decryption; metadata is informational only and a mismatch does not affect correctness.
|
|
379
|
+
|
|
380
|
+
**Single-blob re-key** (re-wrap DEK without touching the file body):
|
|
381
|
+
|
|
382
|
+
```ruby
|
|
383
|
+
svc = ActiveCipherStorage::Adapters::ActiveStorageService.new(wrapped_service: inner)
|
|
384
|
+
|
|
385
|
+
result = svc.rekey(
|
|
386
|
+
"storage/key/for/blob",
|
|
387
|
+
old_provider: old_provider,
|
|
388
|
+
new_provider: new_provider
|
|
389
|
+
)
|
|
390
|
+
# => { status: :rotated }
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
**Batch key rotation** across all blobs for a provider:
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
ActiveCipherStorage::KeyRotation.rotate(
|
|
397
|
+
old_provider: old_kms,
|
|
398
|
+
new_provider: new_kms,
|
|
399
|
+
service: MyEncryptedStorageService.new
|
|
400
|
+
) do |blob, result|
|
|
401
|
+
Rails.logger.info "#{blob.key}: #{result[:status]}"
|
|
402
|
+
end
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
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).
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
409
|
+
## KMS providers
|
|
410
|
+
|
|
411
|
+
### Environment-variable provider
|
|
412
|
+
|
|
413
|
+
```ruby
|
|
414
|
+
# Default env var: ACTIVE_CIPHER_MASTER_KEY
|
|
415
|
+
provider = ActiveCipherStorage::Providers::EnvProvider.new
|
|
416
|
+
|
|
417
|
+
# Custom env var name
|
|
418
|
+
provider = ActiveCipherStorage::Providers::EnvProvider.new(
|
|
419
|
+
env_var: "MYAPP_ENCRYPTION_KEY"
|
|
420
|
+
)
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
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.
|
|
424
|
+
|
|
425
|
+
### AWS KMS provider
|
|
426
|
+
|
|
427
|
+
```ruby
|
|
428
|
+
provider = ActiveCipherStorage::Providers::AwsKmsProvider.new(
|
|
429
|
+
key_id: "arn:aws:kms:us-east-1:123456789:key/mrk-abc123",
|
|
430
|
+
region: "us-east-1",
|
|
431
|
+
|
|
432
|
+
# Bind the DEK to a specific resource. The same context must be
|
|
433
|
+
# present on decrypt — different context = decryption failure.
|
|
434
|
+
encryption_context: { "app" => "my-app", "env" => Rails.env }
|
|
435
|
+
)
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
AWS credentials are resolved through the standard SDK chain (env vars, `~/.aws/credentials`, instance profile, EKS IRSA, etc.).
|
|
439
|
+
|
|
440
|
+
### Custom provider
|
|
441
|
+
|
|
442
|
+
Subclass `ActiveCipherStorage::Providers::Base` and implement the provider contract:
|
|
443
|
+
|
|
444
|
+
```ruby
|
|
445
|
+
class MyVaultProvider < ActiveCipherStorage::Providers::Base
|
|
446
|
+
def provider_id
|
|
447
|
+
"vault" # short ASCII string stored in every file header
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def generate_data_key
|
|
451
|
+
dek = SecureRandom.bytes(32)
|
|
452
|
+
encrypted = vault_client.encrypt(dek) # your KMS/Vault call
|
|
453
|
+
{ plaintext_key: dek, encrypted_key: encrypted }
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def decrypt_data_key(encrypted_key)
|
|
457
|
+
vault_client.decrypt(encrypted_key)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def wrap_data_key(plaintext_dek)
|
|
461
|
+
vault_client.encrypt(plaintext_dek)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
private
|
|
465
|
+
|
|
466
|
+
def vault_client
|
|
467
|
+
# ... your Vault/KMS client setup
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
ActiveCipherStorage.configure do |c|
|
|
472
|
+
c.provider = MyVaultProvider.new
|
|
473
|
+
end
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
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.
|
|
477
|
+
|
|
478
|
+
Implement `rotate_data_key(encrypted_key)` as well if the provider can re-wrap encrypted DEKs without exposing plaintext key material.
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
## Key rotation
|
|
483
|
+
|
|
484
|
+
### AWS KMS automatic rotation
|
|
485
|
+
|
|
486
|
+
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.
|
|
487
|
+
|
|
488
|
+
### Cross-key and cross-provider rotation
|
|
489
|
+
|
|
490
|
+
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.
|
|
491
|
+
|
|
492
|
+
**Dry-run mode** — validate headers without uploading:
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
ActiveCipherStorage::KeyRotation.rotate(
|
|
496
|
+
old_provider: old_kms,
|
|
497
|
+
new_provider: new_kms,
|
|
498
|
+
service: svc,
|
|
499
|
+
dry_run: true
|
|
500
|
+
) do |blob, result|
|
|
501
|
+
puts "#{blob.key}: #{result[:status]}" # :validated or :failed
|
|
502
|
+
end
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
### Low-level DEK re-wrapping
|
|
506
|
+
|
|
507
|
+
```ruby
|
|
508
|
+
# AWS KMS → AWS KMS (ReEncrypt, no plaintext in memory)
|
|
509
|
+
old_provider = ActiveCipherStorage::Providers::AwsKmsProvider.new(key_id: "arn:...old")
|
|
510
|
+
new_provider = ActiveCipherStorage::Providers::AwsKmsProvider.new(key_id: "arn:...new")
|
|
511
|
+
new_dek = old_provider.rotate_data_key(encrypted_dek, destination_key_id: new_provider.key_id)
|
|
512
|
+
|
|
513
|
+
# EnvProvider → EnvProvider
|
|
514
|
+
old_provider = ActiveCipherStorage::Providers::EnvProvider.new(env_var: "OLD_KEY")
|
|
515
|
+
new_provider = ActiveCipherStorage::Providers::EnvProvider.new(env_var: "NEW_KEY")
|
|
516
|
+
new_dek = new_provider.rotate_data_key(encrypted_dek, old_provider: old_provider)
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
## Configuration reference
|
|
522
|
+
|
|
523
|
+
```ruby
|
|
524
|
+
ActiveCipherStorage.configure do |config|
|
|
525
|
+
# Required. A Providers::Base instance or :env / :aws_kms shorthand.
|
|
526
|
+
config.provider = :env
|
|
527
|
+
|
|
528
|
+
# Encryption algorithm. Currently only "aes-256-gcm" is supported.
|
|
529
|
+
config.algorithm = "aes-256-gcm"
|
|
530
|
+
|
|
531
|
+
# Plaintext bytes per chunk in StreamCipher mode.
|
|
532
|
+
# Must be >= 5 MiB for S3 multipart uploads (except the last part).
|
|
533
|
+
config.chunk_size = 5 * 1024 * 1024
|
|
534
|
+
|
|
535
|
+
# Logger instance. Defaults to STDOUT at WARN level.
|
|
536
|
+
config.logger = Rails.logger
|
|
537
|
+
end
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
## Encryption format
|
|
543
|
+
|
|
544
|
+
Every encrypted payload is a self-describing binary blob:
|
|
545
|
+
|
|
546
|
+
```
|
|
547
|
+
HEADER
|
|
548
|
+
[4] Magic bytes "ACS\x01"
|
|
549
|
+
[1] Format version (0x01)
|
|
550
|
+
[1] Algorithm ID (0x01 = AES-256-GCM)
|
|
551
|
+
[1] Flags (bit 0: chunked mode)
|
|
552
|
+
[4] Chunk-size hint (uint32 BE; 0 if non-chunked)
|
|
553
|
+
[2] Provider-ID length (uint16 BE)
|
|
554
|
+
[N] Provider ID (UTF-8, e.g. "env" or "aws_kms")
|
|
555
|
+
[2] Encrypted DEK length (uint16 BE)
|
|
556
|
+
[M] Encrypted DEK bytes
|
|
557
|
+
|
|
558
|
+
NON-CHUNKED PAYLOAD
|
|
559
|
+
[12] IV (random, unique per operation)
|
|
560
|
+
[K] AES-256-GCM ciphertext
|
|
561
|
+
[16] Auth tag
|
|
562
|
+
|
|
563
|
+
CHUNKED PAYLOAD (repeated until final frame)
|
|
564
|
+
[4] Sequence number (1, 2, … or 0xFFFFFFFF = final)
|
|
565
|
+
[12] Chunk IV (random, unique per chunk)
|
|
566
|
+
[4] Ciphertext length (uint32 BE)
|
|
567
|
+
[K] Chunk ciphertext
|
|
568
|
+
[16] Chunk auth tag
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
**Security properties:**
|
|
572
|
+
- Each file uses a fresh DEK, so compromising one file does not affect others.
|
|
573
|
+
- Each chunk (and each non-chunked payload) uses a fresh random IV.
|
|
574
|
+
- The chunk sequence number is AAD, preventing chunk reordering/splicing attacks.
|
|
575
|
+
- Auth tag failure raises `DecryptionError` immediately — no partial plaintext is returned.
|
|
576
|
+
- Unsupported format versions, algorithms, and header flags raise `InvalidFormat` instead of being parsed permissively.
|
|
577
|
+
|
|
578
|
+
---
|
|
579
|
+
|
|
580
|
+
## Security notes
|
|
581
|
+
|
|
582
|
+
| Risk | Mitigation |
|
|
583
|
+
|------|-----------|
|
|
584
|
+
| IV reuse | `SecureRandom.random_bytes` for every encrypt call; the probability of collision is negligible at any realistic scale. |
|
|
585
|
+
| Plaintext DEK in memory | DEK bytes are zeroed with `setbyte(i, 0)` in `ensure` blocks. Ruby GC may retain copies; use locked memory (e.g. via a C extension) for stricter requirements. |
|
|
586
|
+
| Direct uploads | `url_for_direct_upload` raises `UnsupportedOperation` — it is not possible to encrypt client-side with this gem. Use server-side uploads only. |
|
|
587
|
+
| Partial-read oracle | `DecryptionError` is always raised from `cipher.final`; no partial plaintext is ever returned. |
|
|
588
|
+
| Accidental plaintext upload | All upload paths go through the cipher layer; there is no bypass. |
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
## Testing
|
|
593
|
+
|
|
594
|
+
```bash
|
|
595
|
+
# All tests
|
|
596
|
+
bundle exec rake spec
|
|
597
|
+
|
|
598
|
+
# Unit tests only
|
|
599
|
+
bundle exec rake spec:unit
|
|
600
|
+
|
|
601
|
+
# Integration tests only
|
|
602
|
+
bundle exec rake spec:integration
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
Integration tests use in-memory fakes for both Active Storage and S3 — no real AWS credentials or S3 bucket required.
|
|
606
|
+
|
|
607
|
+
---
|
|
608
|
+
|
|
609
|
+
## Contributing
|
|
610
|
+
|
|
611
|
+
Contributions are welcome. Please read `CONTRIBUTING.md` before opening a pull request.
|
|
612
|
+
|
|
613
|
+
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:
|
|
614
|
+
|
|
615
|
+
```bash
|
|
616
|
+
bundle exec rspec
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Do not commit secrets, credentials, `.env` files, local coverage output, or generated gems.
|
|
620
|
+
|
|
621
|
+
---
|
|
622
|
+
|
|
623
|
+
## Security reports
|
|
624
|
+
|
|
625
|
+
Please do not open public GitHub issues for vulnerabilities. Follow `SECURITY.md` and use GitHub private vulnerability reporting if it is available for the repository:
|
|
626
|
+
|
|
627
|
+
https://github.com/codebyjass/active-cipher-storage/security/advisories/new
|
|
628
|
+
|
|
629
|
+
---
|
|
630
|
+
|
|
631
|
+
## License
|
|
632
|
+
|
|
633
|
+
The gem is available as open source under the terms of the MIT License. See `LICENSE`.
|
|
634
|
+
|
|
635
|
+
---
|
|
636
|
+
|
|
637
|
+
## Ruby and Rails compatibility
|
|
638
|
+
|
|
639
|
+
| | Version |
|
|
640
|
+
|--|---------|
|
|
641
|
+
| Ruby | >= 3.2 |
|
|
642
|
+
| Rails / Active Storage | >= 7.0 |
|
|
643
|
+
| aws-sdk-kms | ~> 1.0 (optional) |
|
|
644
|
+
| aws-sdk-s3 | ~> 1.0 (optional) |
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
ActiveCipherStorage is a security-sensitive library. Please report suspected vulnerabilities privately.
|
|
4
|
+
|
|
5
|
+
## Supported Versions
|
|
6
|
+
|
|
7
|
+
Until the first stable release, security fixes target the latest released version and `main`.
|
|
8
|
+
|
|
9
|
+
| Version | Supported |
|
|
10
|
+
| ------- | --------- |
|
|
11
|
+
| 0.1.x | Yes |
|
|
12
|
+
|
|
13
|
+
## Reporting a Vulnerability
|
|
14
|
+
|
|
15
|
+
Do not open a public GitHub issue for a vulnerability.
|
|
16
|
+
|
|
17
|
+
Please report security issues using GitHub's private vulnerability reporting feature if it is enabled for the repository:
|
|
18
|
+
|
|
19
|
+
https://github.com/codebyjass/active-cipher-storage/security/advisories/new
|
|
20
|
+
|
|
21
|
+
If private vulnerability reporting is not available, open a minimal issue asking for a private contact channel without including exploit details.
|
|
22
|
+
|
|
23
|
+
## What To Include
|
|
24
|
+
|
|
25
|
+
Include as much of the following as possible:
|
|
26
|
+
|
|
27
|
+
- Affected version or commit.
|
|
28
|
+
- Description of the issue and impact.
|
|
29
|
+
- Steps to reproduce.
|
|
30
|
+
- Any proof-of-concept code.
|
|
31
|
+
- Suggested mitigation, if known.
|
|
32
|
+
|
|
33
|
+
## Handling
|
|
34
|
+
|
|
35
|
+
Reports will be reviewed as quickly as possible. Valid vulnerabilities will be fixed in a patch release when appropriate, and the changelog will note the security impact without exposing unnecessary exploit detail.
|