active_cipher_storage 1.0.0 → 1.0.2
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 +22 -1
- data/README.md +8 -39
- data/active_cipher_storage.gemspec +6 -5
- data/lib/active_cipher_storage/adapters/active_storage_service.rb +11 -0
- data/lib/active_cipher_storage/adapters/s3_adapter.rb +26 -2
- data/lib/active_cipher_storage/blob_metadata.rb +20 -0
- data/lib/active_cipher_storage/configuration.rb +60 -18
- data/lib/active_cipher_storage/multipart_upload.rb +9 -0
- data/lib/active_cipher_storage/stream_cipher.rb +20 -0
- data/lib/active_cipher_storage/version.rb +1 -1
- metadata +26 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 410a3612b86fe09cba42d171f3319c138f5465ce1277e8820b48fda5917db28e
|
|
4
|
+
data.tar.gz: eca10d2813482d941cd4d04e5388fe4bbe14b72223e5a698da1d050ff10a44ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 213e523764da530299bd8eae9e0c02b06e7870c5abb220fa9e5bc154231ea58d8c1b165969ce9db2603987b2648eaa79d001dddf209e5aa7ebf88c92dac00a16
|
|
7
|
+
data.tar.gz: 3bf3636dcdfe44c4fe7da7d5e14c8aaa40c64586359db288cfc295a426b498c4790c7ff70cc53cafbcf7cee4c5b2e9a1a7de683721faa0990a49189c9ed00080
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.2] - 2026-04-25
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Publish updated RubyGems metadata for Rails Active Storage encryption, Ruby encryption/decryption, S3 streaming, multipart uploads, AES-256-GCM, and AWS KMS discoverability.
|
|
15
|
+
|
|
16
|
+
## [1.0.1] - 2026-04-25
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Back gem configuration with Rails-style ActiveSupport options while preserving the existing public configuration API.
|
|
21
|
+
- Document the Active Storage upload encryption flag and plaintext read compatibility behavior.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Reject reordered streaming frames and trailing bytes after the final encrypted frame.
|
|
26
|
+
- Validate S3 multipart chunk sizes before upload so invalid part sizes fail early.
|
|
27
|
+
- Mark plaintext Active Storage uploads explicitly when encryption is disabled.
|
|
28
|
+
|
|
10
29
|
## [1.0.0] - 2026-04-25
|
|
11
30
|
|
|
12
31
|
### Added
|
|
@@ -20,5 +39,7 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
|
|
|
20
39
|
- Header-only key rotation for re-wrapping encrypted DEKs.
|
|
21
40
|
- Unit and integration coverage for crypto, providers, Active Storage, S3, multipart upload, streaming, metadata, and key rotation.
|
|
22
41
|
|
|
23
|
-
[Unreleased]: https://github.com/codebyjass/active-cipher-storage/compare/v1.0.
|
|
42
|
+
[Unreleased]: https://github.com/codebyjass/active-cipher-storage/compare/v1.0.2...HEAD
|
|
43
|
+
[1.0.2]: https://github.com/codebyjass/active-cipher-storage/compare/v1.0.1...v1.0.2
|
|
44
|
+
[1.0.1]: https://github.com/codebyjass/active-cipher-storage/compare/v1.0.0...v1.0.1
|
|
24
45
|
[1.0.0]: https://github.com/codebyjass/active-cipher-storage/releases/tag/v1.0.0
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/codebyjass/active-cipher-storage/actions/workflows/ruby.yml)
|
|
4
4
|
|
|
5
|
-
|
|
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.
|
|
6
6
|
|
|
7
7
|
ActiveCipherStorage supports three upload paths:
|
|
8
8
|
|
|
@@ -10,8 +10,6 @@ ActiveCipherStorage supports three upload paths:
|
|
|
10
10
|
- **Direct S3 clients** — service objects and non-Rails apps can call `put_encrypted`, `get_decrypted`, and `stream_decrypted`.
|
|
11
11
|
- **Frontend chunk uploads** — the frontend sends plaintext chunks to your backend; the backend encrypts those chunks and uploads encrypted S3 multipart parts.
|
|
12
12
|
|
|
13
|
-
---
|
|
14
|
-
|
|
15
13
|
## Contents
|
|
16
14
|
|
|
17
15
|
1. [How it works](#how-it-works)
|
|
@@ -36,8 +34,6 @@ ActiveCipherStorage supports three upload paths:
|
|
|
36
34
|
17. [License](#license)
|
|
37
35
|
18. [Ruby and Rails compatibility](#ruby-and-rails-compatibility)
|
|
38
36
|
|
|
39
|
-
---
|
|
40
|
-
|
|
41
37
|
## How it works
|
|
42
38
|
|
|
43
39
|
Every encrypted file is self-contained. No external metadata store is needed.
|
|
@@ -72,8 +68,6 @@ Decryption reverses the flow: the KMS provider unwraps the DEK from the header,
|
|
|
72
68
|
|
|
73
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.
|
|
74
70
|
|
|
75
|
-
---
|
|
76
|
-
|
|
77
71
|
## Installation
|
|
78
72
|
|
|
79
73
|
```ruby
|
|
@@ -91,8 +85,6 @@ gem "aws-sdk-s3"
|
|
|
91
85
|
bundle install
|
|
92
86
|
```
|
|
93
87
|
|
|
94
|
-
---
|
|
95
|
-
|
|
96
88
|
## Rails / Active Storage setup
|
|
97
89
|
|
|
98
90
|
### 1. Configure a KMS provider
|
|
@@ -113,6 +105,7 @@ ActiveCipherStorage.configure do |config|
|
|
|
113
105
|
|
|
114
106
|
# Tuning (optional)
|
|
115
107
|
config.chunk_size = 5 * 1024 * 1024 # 5 MiB per chunk (default)
|
|
108
|
+
config.encrypt_uploads = true # set false to store new Active Storage uploads as plaintext
|
|
116
109
|
end
|
|
117
110
|
```
|
|
118
111
|
|
|
@@ -163,9 +156,9 @@ url = rails_blob_url(user.document)
|
|
|
163
156
|
|
|
164
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.
|
|
165
158
|
|
|
166
|
-
|
|
159
|
+
`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.
|
|
167
160
|
|
|
168
|
-
|
|
161
|
+
Direct Active Storage browser uploads are intentionally disabled because they bypass the backend encryption layer.
|
|
169
162
|
|
|
170
163
|
## Standalone S3 usage
|
|
171
164
|
|
|
@@ -202,8 +195,6 @@ s3 = ActiveCipherStorage::Adapters::S3Adapter.new(
|
|
|
202
195
|
)
|
|
203
196
|
```
|
|
204
197
|
|
|
205
|
-
---
|
|
206
|
-
|
|
207
198
|
## Chunked multipart upload
|
|
208
199
|
|
|
209
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.
|
|
@@ -280,8 +271,6 @@ uploader = ActiveCipherStorage::EncryptedMultipartUpload.new(
|
|
|
280
271
|
|
|
281
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.
|
|
282
273
|
|
|
283
|
-
---
|
|
284
|
-
|
|
285
274
|
## Streaming download
|
|
286
275
|
|
|
287
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.
|
|
@@ -317,8 +306,6 @@ end
|
|
|
317
306
|
|
|
318
307
|
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
308
|
|
|
320
|
-
---
|
|
321
|
-
|
|
322
309
|
## Manual encrypt / decrypt
|
|
323
310
|
|
|
324
311
|
Use `Cipher` (in-memory) or `StreamCipher` (chunked, constant memory):
|
|
@@ -354,8 +341,6 @@ File.open("large.bin.enc", "rb") do |input|
|
|
|
354
341
|
end
|
|
355
342
|
```
|
|
356
343
|
|
|
357
|
-
---
|
|
358
|
-
|
|
359
344
|
## Blob metadata
|
|
360
345
|
|
|
361
346
|
When using the Rails Active Storage adapter, encryption metadata is automatically written to `ActiveStorage::Blob#metadata` after each upload:
|
|
@@ -404,8 +389,6 @@ end
|
|
|
404
389
|
|
|
405
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).
|
|
406
391
|
|
|
407
|
-
---
|
|
408
|
-
|
|
409
392
|
## KMS providers
|
|
410
393
|
|
|
411
394
|
### Environment-variable provider
|
|
@@ -477,8 +460,6 @@ The `provider_id` is embedded in every encrypted file. Routing at decrypt time i
|
|
|
477
460
|
|
|
478
461
|
Implement `rotate_data_key(encrypted_key)` as well if the provider can re-wrap encrypted DEKs without exposing plaintext key material.
|
|
479
462
|
|
|
480
|
-
---
|
|
481
|
-
|
|
482
463
|
## Key rotation
|
|
483
464
|
|
|
484
465
|
### AWS KMS automatic rotation
|
|
@@ -516,8 +497,6 @@ new_provider = ActiveCipherStorage::Providers::EnvProvider.new(env_var: "NEW_KEY
|
|
|
516
497
|
new_dek = new_provider.rotate_data_key(encrypted_dek, old_provider: old_provider)
|
|
517
498
|
```
|
|
518
499
|
|
|
519
|
-
---
|
|
520
|
-
|
|
521
500
|
## Configuration reference
|
|
522
501
|
|
|
523
502
|
```ruby
|
|
@@ -532,13 +511,15 @@ ActiveCipherStorage.configure do |config|
|
|
|
532
511
|
# Must be >= 5 MiB for S3 multipart uploads (except the last part).
|
|
533
512
|
config.chunk_size = 5 * 1024 * 1024
|
|
534
513
|
|
|
514
|
+
# Controls new Active Storage uploads only. Downloads always auto-detect
|
|
515
|
+
# encrypted vs. plaintext payloads by the ACS header.
|
|
516
|
+
config.encrypt_uploads = true
|
|
517
|
+
|
|
535
518
|
# Logger instance. Defaults to STDOUT at WARN level.
|
|
536
519
|
config.logger = Rails.logger
|
|
537
520
|
end
|
|
538
521
|
```
|
|
539
522
|
|
|
540
|
-
---
|
|
541
|
-
|
|
542
523
|
## Encryption format
|
|
543
524
|
|
|
544
525
|
Every encrypted payload is a self-describing binary blob:
|
|
@@ -575,8 +556,6 @@ CHUNKED PAYLOAD (repeated until final frame)
|
|
|
575
556
|
- Auth tag failure raises `DecryptionError` immediately — no partial plaintext is returned.
|
|
576
557
|
- Unsupported format versions, algorithms, and header flags raise `InvalidFormat` instead of being parsed permissively.
|
|
577
558
|
|
|
578
|
-
---
|
|
579
|
-
|
|
580
559
|
## Security notes
|
|
581
560
|
|
|
582
561
|
| Risk | Mitigation |
|
|
@@ -587,8 +566,6 @@ CHUNKED PAYLOAD (repeated until final frame)
|
|
|
587
566
|
| Partial-read oracle | `DecryptionError` is always raised from `cipher.final`; no partial plaintext is ever returned. |
|
|
588
567
|
| Accidental plaintext upload | All upload paths go through the cipher layer; there is no bypass. |
|
|
589
568
|
|
|
590
|
-
---
|
|
591
|
-
|
|
592
569
|
## Testing
|
|
593
570
|
|
|
594
571
|
```bash
|
|
@@ -604,8 +581,6 @@ bundle exec rake spec:integration
|
|
|
604
581
|
|
|
605
582
|
Integration tests use in-memory fakes for both Active Storage and S3 — no real AWS credentials or S3 bucket required.
|
|
606
583
|
|
|
607
|
-
---
|
|
608
|
-
|
|
609
584
|
## Contributing
|
|
610
585
|
|
|
611
586
|
Contributions are welcome. Please read `CONTRIBUTING.md` before opening a pull request.
|
|
@@ -618,22 +593,16 @@ bundle exec rspec
|
|
|
618
593
|
|
|
619
594
|
Do not commit secrets, credentials, `.env` files, local coverage output, or generated gems.
|
|
620
595
|
|
|
621
|
-
---
|
|
622
|
-
|
|
623
596
|
## Security reports
|
|
624
597
|
|
|
625
598
|
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
599
|
|
|
627
600
|
https://github.com/codebyjass/active-cipher-storage/security/advisories/new
|
|
628
601
|
|
|
629
|
-
---
|
|
630
|
-
|
|
631
602
|
## License
|
|
632
603
|
|
|
633
604
|
The gem is available as open source under the terms of the MIT License. See `LICENSE`.
|
|
634
605
|
|
|
635
|
-
---
|
|
636
|
-
|
|
637
606
|
## Ruby and Rails compatibility
|
|
638
607
|
|
|
639
608
|
| | Version |
|
|
@@ -6,12 +6,12 @@ Gem::Specification.new do |spec|
|
|
|
6
6
|
spec.authors = ["Jaspreet Singh"]
|
|
7
7
|
spec.email = ["codebyjass@users.noreply.github.com"]
|
|
8
8
|
|
|
9
|
-
spec.summary = "
|
|
9
|
+
spec.summary = "Rails Active Storage encryption for Ruby apps"
|
|
10
10
|
spec.description = <<~DESC
|
|
11
|
-
active_cipher_storage
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
active_cipher_storage encrypts and decrypts Rails Active Storage files with
|
|
12
|
+
AES-256-GCM envelope encryption. It supports AWS S3, streaming downloads,
|
|
13
|
+
multipart uploads, AWS KMS, environment-variable keys, and custom key
|
|
14
|
+
providers for Ruby and Rails applications.
|
|
15
15
|
DESC
|
|
16
16
|
|
|
17
17
|
spec.homepage = "https://github.com/codebyjass/active-cipher-storage"
|
|
@@ -38,6 +38,7 @@ Gem::Specification.new do |spec|
|
|
|
38
38
|
spec.require_paths = ["lib"]
|
|
39
39
|
|
|
40
40
|
# Core — no runtime dep on Rails or AWS
|
|
41
|
+
spec.add_dependency "activesupport", ">= 7.0", "< 9.0"
|
|
41
42
|
spec.add_dependency "concurrent-ruby", "~> 1.2"
|
|
42
43
|
|
|
43
44
|
# Optional integrations — loaded only when the relevant adapter is used
|
|
@@ -33,6 +33,17 @@ module ActiveCipherStorage
|
|
|
33
33
|
|
|
34
34
|
def upload(key, io, checksum: nil, content_type: nil, filename: nil,
|
|
35
35
|
disposition: nil, custom_metadata: {})
|
|
36
|
+
unless ActiveCipherStorage.configuration.encrypt_uploads
|
|
37
|
+
@inner.upload(key, io,
|
|
38
|
+
checksum: checksum,
|
|
39
|
+
content_type: content_type,
|
|
40
|
+
filename: filename,
|
|
41
|
+
disposition: disposition,
|
|
42
|
+
custom_metadata: custom_metadata)
|
|
43
|
+
BlobMetadata.write_plaintext(key)
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
36
47
|
@inner.upload(key, encrypt_io(io),
|
|
37
48
|
checksum: nil, # checksum is over plaintext; skip for ciphertext
|
|
38
49
|
content_type: "application/octet-stream",
|
|
@@ -65,7 +65,7 @@ module ActiveCipherStorage
|
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def multipart_put(key, io, **options)
|
|
68
|
-
|
|
68
|
+
validate_multipart_chunk_size!
|
|
69
69
|
upload_id = s3.create_multipart_upload(bucket: @bucket, key: key,
|
|
70
70
|
**upload_options(options)).upload_id
|
|
71
71
|
parts = stream_multipart_parts(key, io, upload_id)
|
|
@@ -147,6 +147,14 @@ module ActiveCipherStorage
|
|
|
147
147
|
c
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
+
def validate_multipart_chunk_size!
|
|
151
|
+
min_size = Configuration::MINIMUM_S3_MULTIPART_PART_SIZE
|
|
152
|
+
return if @config.chunk_size >= min_size
|
|
153
|
+
|
|
154
|
+
raise ArgumentError,
|
|
155
|
+
"chunk_size must be at least 5 MiB for S3 multipart uploads"
|
|
156
|
+
end
|
|
157
|
+
|
|
150
158
|
def s3
|
|
151
159
|
@s3 ||= begin
|
|
152
160
|
require "aws-sdk-s3"
|
|
@@ -180,10 +188,16 @@ module ActiveCipherStorage
|
|
|
180
188
|
@dek = nil
|
|
181
189
|
@header_done = false
|
|
182
190
|
@done = false
|
|
191
|
+
@expected_seq = 1
|
|
183
192
|
end
|
|
184
193
|
|
|
185
194
|
def push(bytes, &block)
|
|
186
|
-
|
|
195
|
+
if @done
|
|
196
|
+
raise Errors::InvalidFormat, "Trailing bytes after final frame" unless bytes.empty?
|
|
197
|
+
|
|
198
|
+
return
|
|
199
|
+
end
|
|
200
|
+
|
|
187
201
|
@buffer += bytes.b
|
|
188
202
|
try_parse_header unless @header_done
|
|
189
203
|
drain_frames(&block) if @header_done
|
|
@@ -191,6 +205,7 @@ module ActiveCipherStorage
|
|
|
191
205
|
|
|
192
206
|
def finish!
|
|
193
207
|
raise Errors::InvalidFormat, "Stream ended before final frame" unless @done
|
|
208
|
+
raise Errors::InvalidFormat, "Trailing bytes after final frame" unless @buffer.empty?
|
|
194
209
|
ensure
|
|
195
210
|
zero_bytes!(@dek)
|
|
196
211
|
end
|
|
@@ -227,12 +242,21 @@ module ActiveCipherStorage
|
|
|
227
242
|
frame = Format.read_chunk(StringIO.new(@buffer.byteslice(0, frame_size)))
|
|
228
243
|
@buffer = (@buffer.byteslice(frame_size..) || "".b).b
|
|
229
244
|
|
|
245
|
+
validate_frame_sequence!(frame[:seq])
|
|
230
246
|
plaintext = decrypt_frame(frame)
|
|
231
247
|
block.call(plaintext) unless plaintext.empty?
|
|
232
248
|
@done = (frame[:seq] == Format::FINAL_SEQ)
|
|
249
|
+
@expected_seq += 1 unless @done
|
|
233
250
|
end
|
|
234
251
|
end
|
|
235
252
|
|
|
253
|
+
def validate_frame_sequence!(seq)
|
|
254
|
+
return if [Format::FINAL_SEQ, @expected_seq].include?(seq)
|
|
255
|
+
|
|
256
|
+
raise Errors::InvalidFormat,
|
|
257
|
+
"Unexpected chunk sequence: expected #{@expected_seq}, got #{seq}"
|
|
258
|
+
end
|
|
259
|
+
|
|
236
260
|
def decrypt_frame(frame)
|
|
237
261
|
c = OpenSSL::Cipher.new(Cipher::OPENSSL_ALGO)
|
|
238
262
|
c.decrypt
|
|
@@ -31,6 +31,26 @@ module ActiveCipherStorage
|
|
|
31
31
|
)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
def self.write_plaintext(storage_key)
|
|
35
|
+
return unless active_storage_available?
|
|
36
|
+
|
|
37
|
+
blob = ActiveStorage::Blob.find_by(key: storage_key)
|
|
38
|
+
return unless blob
|
|
39
|
+
|
|
40
|
+
blob.update_columns(
|
|
41
|
+
metadata: blob.metadata.merge(
|
|
42
|
+
"encrypted" => false,
|
|
43
|
+
"cipher_version" => nil,
|
|
44
|
+
"provider_id" => nil,
|
|
45
|
+
"kms_key_id" => nil
|
|
46
|
+
).compact
|
|
47
|
+
)
|
|
48
|
+
rescue => e
|
|
49
|
+
ActiveCipherStorage.configuration.logger.warn(
|
|
50
|
+
"[ActiveCipherStorage] Could not write plaintext blob metadata for #{storage_key}: #{e.message}"
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
34
54
|
def self.update_after_rotation(storage_key, new_provider)
|
|
35
55
|
return unless active_storage_available?
|
|
36
56
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "logger"
|
|
2
|
+
require "active_support/ordered_options"
|
|
2
3
|
|
|
3
4
|
module ActiveCipherStorage
|
|
4
5
|
class Configuration
|
|
@@ -7,40 +8,81 @@ module ActiveCipherStorage
|
|
|
7
8
|
|
|
8
9
|
# Bytes per plaintext chunk in streaming mode (default 5 MiB — matches the
|
|
9
10
|
# minimum S3 multipart part size, so each chunk maps to exactly one part).
|
|
11
|
+
MINIMUM_S3_MULTIPART_PART_SIZE = 5 * 1024 * 1024
|
|
10
12
|
DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024
|
|
11
13
|
|
|
12
|
-
attr_reader
|
|
13
|
-
attr_accessor :algorithm, :chunk_size, :logger
|
|
14
|
+
attr_reader :config
|
|
14
15
|
|
|
15
16
|
def initialize
|
|
16
|
-
@
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
@config = ActiveSupport::OrderedOptions.new
|
|
18
|
+
self.algorithm = "aes-256-gcm"
|
|
19
|
+
self.chunk_size = DEFAULT_CHUNK_SIZE
|
|
20
|
+
self.encrypt_uploads = true
|
|
21
|
+
self.logger = Logger.new($stdout, level: Logger::WARN)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def algorithm
|
|
25
|
+
config.algorithm
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def algorithm=(value)
|
|
29
|
+
config.algorithm = value
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def chunk_size
|
|
33
|
+
config.chunk_size
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def chunk_size=(value)
|
|
37
|
+
config.chunk_size = value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def encrypt_uploads
|
|
41
|
+
config.encrypt_uploads
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def encrypt_uploads=(value)
|
|
45
|
+
config.encrypt_uploads = value
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def logger
|
|
49
|
+
config.logger
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def logger=(value)
|
|
53
|
+
config.logger = value
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def provider
|
|
57
|
+
config.provider
|
|
20
58
|
end
|
|
21
59
|
|
|
22
60
|
# Accept a provider instance or a symbol shorthand (:env, :aws_kms).
|
|
23
61
|
def provider=(value)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
62
|
+
config.provider = case value
|
|
63
|
+
when Symbol then resolve_provider(value)
|
|
64
|
+
when Providers::Base then value
|
|
65
|
+
else
|
|
66
|
+
raise ArgumentError,
|
|
67
|
+
"provider must be a Providers::Base instance or " \
|
|
68
|
+
"one of :env, :aws_kms — got #{value.inspect}"
|
|
69
|
+
end
|
|
32
70
|
end
|
|
33
71
|
|
|
34
72
|
def validate!
|
|
35
73
|
raise ProviderError, "No KMS provider configured. " \
|
|
36
|
-
"Set ActiveCipherStorage.configuration.provider." unless
|
|
74
|
+
"Set ActiveCipherStorage.configuration.provider." unless provider
|
|
37
75
|
|
|
38
|
-
unless ALGORITHMS.include?(
|
|
39
|
-
raise ArgumentError, "Unsupported algorithm: #{
|
|
76
|
+
unless ALGORITHMS.include?(algorithm)
|
|
77
|
+
raise ArgumentError, "Unsupported algorithm: #{algorithm.inspect}. " \
|
|
40
78
|
"Supported: #{ALGORITHMS.join(', ')}"
|
|
41
79
|
end
|
|
42
80
|
|
|
43
|
-
raise ArgumentError, "chunk_size must be positive" unless
|
|
81
|
+
raise ArgumentError, "chunk_size must be positive" unless chunk_size.positive?
|
|
82
|
+
|
|
83
|
+
return if [true, false].include?(encrypt_uploads)
|
|
84
|
+
|
|
85
|
+
raise ArgumentError, "encrypt_uploads must be true or false"
|
|
44
86
|
end
|
|
45
87
|
|
|
46
88
|
private
|
|
@@ -31,6 +31,7 @@ module ActiveCipherStorage
|
|
|
31
31
|
@config = config || ActiveCipherStorage.configuration
|
|
32
32
|
@store = store || MemorySessionStore.new
|
|
33
33
|
@config.validate!
|
|
34
|
+
validate_multipart_chunk_size!
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
# Starts a new multipart upload. Returns an opaque session_id.
|
|
@@ -166,6 +167,14 @@ module ActiveCipherStorage
|
|
|
166
167
|
@store.write(id, data, expires_in: SESSION_TTL)
|
|
167
168
|
end
|
|
168
169
|
|
|
170
|
+
def validate_multipart_chunk_size!
|
|
171
|
+
min_size = Configuration::MINIMUM_S3_MULTIPART_PART_SIZE
|
|
172
|
+
return if @config.chunk_size >= min_size
|
|
173
|
+
|
|
174
|
+
raise ArgumentError,
|
|
175
|
+
"chunk_size must be at least 5 MiB for S3 multipart uploads"
|
|
176
|
+
end
|
|
177
|
+
|
|
169
178
|
# Thread-safe in-memory session store backed by Concurrent::Map.
|
|
170
179
|
# Replace with a Rails.cache wrapper for multi-process deployments.
|
|
171
180
|
class MemorySessionStore
|
|
@@ -47,13 +47,19 @@ module ActiveCipherStorage
|
|
|
47
47
|
raise Errors::InvalidFormat, "Payload is not chunked; use Cipher#decrypt" unless header.chunked
|
|
48
48
|
|
|
49
49
|
key = @provider.decrypt_data_key(header.encrypted_dek)
|
|
50
|
+
expected_seq = 1
|
|
50
51
|
loop do
|
|
51
52
|
frame = Format.read_chunk(input_io)
|
|
52
53
|
raise Errors::InvalidFormat, "Unexpected end of stream — missing final frame" if frame.nil?
|
|
53
54
|
|
|
55
|
+
validate_frame_sequence!(frame[:seq], expected_seq)
|
|
54
56
|
output_io.write(decrypt_chunk(frame[:ciphertext], key, frame[:iv], frame[:auth_tag], frame[:seq]))
|
|
55
57
|
break if frame[:seq] == Format::FINAL_SEQ
|
|
58
|
+
|
|
59
|
+
expected_seq += 1
|
|
56
60
|
end
|
|
61
|
+
|
|
62
|
+
ensure_no_trailing_bytes!(input_io)
|
|
57
63
|
ensure
|
|
58
64
|
zero_bytes!(key)
|
|
59
65
|
end
|
|
@@ -89,6 +95,20 @@ module ActiveCipherStorage
|
|
|
89
95
|
"Authentication failed on chunk seq=#{seq} — data may be tampered"
|
|
90
96
|
end
|
|
91
97
|
|
|
98
|
+
def validate_frame_sequence!(seq, expected_seq)
|
|
99
|
+
return if seq == Format::FINAL_SEQ || seq == expected_seq
|
|
100
|
+
|
|
101
|
+
raise Errors::InvalidFormat,
|
|
102
|
+
"Unexpected chunk sequence: expected #{expected_seq}, got #{seq}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def ensure_no_trailing_bytes!(input_io)
|
|
106
|
+
trailing = input_io.read(1)
|
|
107
|
+
return if trailing.nil? || trailing.empty?
|
|
108
|
+
|
|
109
|
+
raise Errors::InvalidFormat, "Trailing bytes after final frame"
|
|
110
|
+
end
|
|
111
|
+
|
|
92
112
|
def build_cipher(mode, key, iv, auth_tag, seq)
|
|
93
113
|
c = OpenSSL::Cipher.new(Cipher::OPENSSL_ALGO)
|
|
94
114
|
mode == :encrypt ? c.encrypt : c.decrypt
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_cipher_storage
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaspreet Singh
|
|
@@ -10,6 +10,26 @@ bindir: bin
|
|
|
10
10
|
cert_chain: []
|
|
11
11
|
date: 2026-04-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activesupport
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.0'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '9.0'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '7.0'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '9.0'
|
|
13
33
|
- !ruby/object:Gem::Dependency
|
|
14
34
|
name: concurrent-ruby
|
|
15
35
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -157,10 +177,10 @@ dependencies:
|
|
|
157
177
|
- !ruby/object:Gem::Version
|
|
158
178
|
version: '13.0'
|
|
159
179
|
description: |
|
|
160
|
-
active_cipher_storage
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
180
|
+
active_cipher_storage encrypts and decrypts Rails Active Storage files with
|
|
181
|
+
AES-256-GCM envelope encryption. It supports AWS S3, streaming downloads,
|
|
182
|
+
multipart uploads, AWS KMS, environment-variable keys, and custom key
|
|
183
|
+
providers for Ruby and Rails applications.
|
|
164
184
|
email:
|
|
165
185
|
- codebyjass@users.noreply.github.com
|
|
166
186
|
executables: []
|
|
@@ -219,6 +239,5 @@ requirements: []
|
|
|
219
239
|
rubygems_version: 3.4.19
|
|
220
240
|
signing_key:
|
|
221
241
|
specification_version: 4
|
|
222
|
-
summary:
|
|
223
|
-
providers
|
|
242
|
+
summary: Rails Active Storage encryption for Ruby apps
|
|
224
243
|
test_files: []
|