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.
data/README.md ADDED
@@ -0,0 +1,644 @@
1
+ # ActiveCipherStorage
2
+
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
+
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.