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.
@@ -1,10 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage"
1
4
  require "active_storage/service"
2
- require "active_cipher_storage/adapters/active_storage_service"
5
+ require "active_cipher_storage"
6
+ require "active_cipher_storage/cipher"
7
+ require "active_cipher_storage/stream_cipher"
8
+ require "active_cipher_storage/errors"
9
+ require "active_cipher_storage/format"
10
+ require "active_cipher_storage/blob_metadata"
3
11
 
4
12
  module ActiveStorage
5
- class Service
6
- unless const_defined?(:ActiveCipherStorageService, false)
7
- ActiveCipherStorageService = ::ActiveCipherStorage::Adapters::ActiveStorageService
13
+ # Encrypting wrapper around another Active Storage service (e.g. S3). Configure
14
+ # in +config/storage.yml+ with +service: ActiveCipherStorage+ and
15
+ # +wrapped_service:+ pointing at a named service.
16
+ #
17
+ # See the gem README for behavior and caveats.
18
+ class Service::ActiveCipherStorageService < Service
19
+ attr_reader :inner
20
+
21
+ def self.build(configurator:, name:, wrapped_service:, service: nil, **service_config)
22
+ new(wrapped_service: configurator.build(wrapped_service), **service_config).tap do |instance|
23
+ instance.name = name.to_s
24
+ end
25
+ end
26
+
27
+ def initialize(wrapped_service:, public: nil,
28
+ chunk_size: ActiveCipherStorage::Configuration::DEFAULT_CHUNK_SIZE, **)
29
+ @inner = wrapped_service
30
+ @chunk_size = Integer(chunk_size)
31
+ @cipher = ActiveCipherStorage::Cipher.new
32
+ @stream_cipher = ActiveCipherStorage::StreamCipher.new(chunk_size: @chunk_size)
33
+ @public =
34
+ if public.nil?
35
+ wrapped_service.respond_to?(:public?) ? wrapped_service.public? : false
36
+ else
37
+ public
38
+ end
39
+ end
40
+
41
+ def upload(key, io, checksum: nil, **options)
42
+ instrument :upload, key: key, checksum: checksum do
43
+ upload_without_instrument(key, io, checksum: checksum, **options)
44
+ end
45
+ end
46
+
47
+ def download(key, &block)
48
+ if block_given?
49
+ instrument :streaming_download, key: key do
50
+ plain = download_without_block(key)
51
+ yield plain
52
+ end
53
+ else
54
+ instrument :download, key: key do
55
+ download_without_block(key)
56
+ end
57
+ end
58
+ end
59
+
60
+ def download_chunk(key, range)
61
+ instrument :download_chunk, key: key, range: range do
62
+ download_without_block(key).b[range]
63
+ end
64
+ end
65
+
66
+ # Returns stored ciphertext bytes (for custom tooling, backups, or migrations).
67
+ def download_raw(key)
68
+ collect_download(key)
69
+ end
70
+
71
+ # Writes raw ciphertext bytes without encrypting (for advanced use only).
72
+ def upload_raw(key, io)
73
+ instrument :upload, key: key, checksum: nil do
74
+ inner.upload(key, io, content_type: "application/octet-stream")
75
+ end
76
+ end
77
+
78
+ def delete(key)
79
+ instrument :delete, key: key do
80
+ inner.delete(key)
81
+ end
82
+ end
83
+
84
+ def delete_prefixed(prefix)
85
+ instrument :delete_prefixed, prefix: prefix do
86
+ inner.delete_prefixed(prefix)
87
+ end
88
+ end
89
+
90
+ def exist?(key)
91
+ instrument :exist, key: key do |payload|
92
+ answer = inner.exist?(key)
93
+ payload[:exist] = answer
94
+ answer
95
+ end
96
+ end
97
+
98
+ def update_metadata(key, **metadata)
99
+ inner.update_metadata(key, **metadata)
100
+ end
101
+
102
+ def compose(source_keys, destination_key, **options)
103
+ inner.compose(source_keys, destination_key, **options)
104
+ end
105
+
106
+ def path_for(key)
107
+ unless inner.respond_to?(:path_for)
108
+ raise NotImplementedError,
109
+ "#{inner.class.name} does not implement path_for — use a disk-backed " \
110
+ "inner service if you need local paths"
111
+ end
112
+
113
+ inner.path_for(key)
114
+ end
115
+
116
+ def url_for_direct_upload(*)
117
+ raise ActiveCipherStorage::Errors::UnsupportedOperation,
118
+ "Direct uploads bypass encryption — use server-side upload instead"
119
+ end
120
+
121
+ def headers_for_direct_upload(*) = {}
122
+
123
+ private
124
+
125
+ def private_url(key, **options)
126
+ inner.send(:private_url, key, **options)
127
+ end
128
+
129
+ def public_url(key, **options)
130
+ inner.send(:public_url, key, **options)
131
+ end
132
+
133
+ def upload_without_instrument(key, io, checksum: nil, **options)
134
+ unless ActiveCipherStorage.configuration.encrypt_uploads
135
+ inner.upload(key, io, checksum: checksum, **options)
136
+ ActiveCipherStorage::BlobMetadata.write_plaintext(key)
137
+ return
138
+ end
139
+
140
+ inner.upload(key, encrypt_io(io), **options,
141
+ checksum: nil,
142
+ content_type: "application/octet-stream")
143
+
144
+ ActiveCipherStorage::BlobMetadata.write(key, ActiveCipherStorage.configuration.provider)
145
+ end
146
+
147
+ def download_without_block(key)
148
+ raw = collect_download(key)
149
+
150
+ return raw unless cipher_payload?(raw)
151
+
152
+ decrypt_raw(raw)
153
+ end
154
+
155
+ def encrypt_io(io)
156
+ if io.respond_to?(:size) && io.size && io.size > @chunk_size
157
+ @stream_cipher.encrypt_to_io(io)
158
+ else
159
+ StringIO.new(@cipher.encrypt(io))
160
+ end
161
+ end
162
+
163
+ def collect_download(key)
164
+ buffer = StringIO.new("".b)
165
+ result = inner.download(key) { |chunk| buffer.write(chunk) }
166
+ buffer.write(result.b) if buffer.pos.zero? && result.is_a?(String)
167
+ buffer.string
168
+ end
169
+
170
+ def decrypt_raw(raw)
171
+ io = StringIO.new(raw.b)
172
+ header = ActiveCipherStorage::Format.read_header(io)
173
+ io.rewind
174
+ header.chunked ? @stream_cipher.decrypt_to_io(io).read : @cipher.decrypt(io)
175
+ end
176
+
177
+ def cipher_payload?(data)
178
+ data.b.start_with?(ActiveCipherStorage::Format::MAGIC)
179
+ end
180
+
181
+ # Base implementation uses +Array#third+ (ActiveSupport); loading order may not
182
+ # include array extensions, so we keep a stable notification label.
183
+ def service_name
184
+ "ActiveCipherStorage"
8
185
  end
9
186
  end
10
187
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_cipher_storage
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaspreet Singh
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-25 00:00:00.000000000 Z
11
+ date: 2026-06-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -194,7 +194,6 @@ files:
194
194
  - SECURITY.md
195
195
  - active_cipher_storage.gemspec
196
196
  - lib/active_cipher_storage.rb
197
- - lib/active_cipher_storage/adapters/active_storage_service.rb
198
197
  - lib/active_cipher_storage/adapters/s3_adapter.rb
199
198
  - lib/active_cipher_storage/blob_metadata.rb
200
199
  - lib/active_cipher_storage/cipher.rb
@@ -202,7 +201,6 @@ files:
202
201
  - lib/active_cipher_storage/engine.rb
203
202
  - lib/active_cipher_storage/errors.rb
204
203
  - lib/active_cipher_storage/format.rb
205
- - lib/active_cipher_storage/key_rotation.rb
206
204
  - lib/active_cipher_storage/key_utils.rb
207
205
  - lib/active_cipher_storage/multipart_upload.rb
208
206
  - lib/active_cipher_storage/providers/aws_kms_provider.rb
@@ -1,140 +0,0 @@
1
- module ActiveCipherStorage
2
- module Adapters
3
- # Active Storage service that transparently encrypts uploads and decrypts
4
- # downloads. Configure in config/storage.yml:
5
- #
6
- # encrypted_s3:
7
- # service: ActiveCipherStorage
8
- # wrapped_service: s3
9
- #
10
- # Backward compatibility
11
- # ───────────────────────
12
- # Blobs uploaded before encryption was enabled are detected via the "ACS\x01"
13
- # magic header. If the magic is absent the raw bytes are returned as-is,
14
- # so the service is safe to enable on a bucket with existing plaintext objects.
15
- #
16
- # Range requests (download_chunk) must decrypt the full blob first because
17
- # GCM authentication requires the complete ciphertext before any plaintext
18
- # can be safely released.
19
- class ActiveStorageService
20
- BlobRef = Struct.new(:key)
21
-
22
- attr_reader :inner
23
-
24
- def self.build(configurator:, wrapped_service:, **kwargs)
25
- new(wrapped_service: configurator.build(wrapped_service), **kwargs)
26
- end
27
-
28
- def initialize(wrapped_service:, **_kwargs)
29
- @inner = wrapped_service
30
- @cipher = Cipher.new
31
- @stream_cipher = StreamCipher.new
32
- end
33
-
34
- def upload(key, io, checksum: nil, content_type: nil, filename: nil,
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
-
47
- @inner.upload(key, encrypt_io(io),
48
- checksum: nil, # checksum is over plaintext; skip for ciphertext
49
- content_type: "application/octet-stream",
50
- filename: filename,
51
- disposition: disposition,
52
- custom_metadata: custom_metadata)
53
-
54
- BlobMetadata.write(key, ActiveCipherStorage.configuration.provider)
55
- end
56
-
57
- def download(key, &block)
58
- raw = collect_download(key)
59
-
60
- # Legacy plaintext blob — no magic header present.
61
- return (block ? yield(raw) : raw) unless cipher_payload?(raw)
62
-
63
- plaintext = decrypt_raw(raw)
64
- block ? yield(plaintext) : plaintext
65
- end
66
-
67
- def download_chunk(key, range)
68
- download(key).b[range]
69
- end
70
-
71
- # Used by KeyRotation to fetch raw ciphertext without decrypting.
72
- def download_raw(key)
73
- collect_download(key)
74
- end
75
-
76
- # Used by KeyRotation to overwrite a blob's bytes without re-encrypting.
77
- def upload_raw(key, io)
78
- @inner.upload(key, io, content_type: "application/octet-stream")
79
- end
80
-
81
- # Re-wraps the DEK in a single blob's header under new_provider without
82
- # decrypting or re-encrypting the file body.
83
- def rekey(key, old_provider:, new_provider:)
84
- KeyRotation.rotate_blob(
85
- BlobRef.new(key),
86
- old_provider: old_provider,
87
- new_provider: new_provider,
88
- service: self
89
- )
90
- end
91
-
92
- def delete(key) = @inner.delete(key)
93
- def delete_prefixed(pfx) = @inner.delete_prefixed(pfx)
94
- def exist?(key) = @inner.exist?(key)
95
-
96
- def url(key, expires_in:, filename:, content_type:, disposition:, **)
97
- @inner.url(key, expires_in: expires_in, filename: filename,
98
- content_type: content_type, disposition: disposition)
99
- end
100
-
101
- def url_for_direct_upload(*)
102
- raise Errors::UnsupportedOperation,
103
- "Direct uploads bypass encryption — use server-side upload instead"
104
- end
105
-
106
- def headers_for_direct_upload(*) = {}
107
-
108
- private
109
-
110
- def encrypt_io(io)
111
- config = ActiveCipherStorage.configuration
112
- if io.respond_to?(:size) && io.size && io.size > config.chunk_size
113
- @stream_cipher.encrypt_to_io(io)
114
- else
115
- StringIO.new(@cipher.encrypt(io))
116
- end
117
- end
118
-
119
- def collect_download(key)
120
- buffer = StringIO.new("".b)
121
- result = @inner.download(key) { |chunk| buffer.write(chunk) }
122
- # Some services return data instead of yielding; handle both.
123
- buffer.write(result.b) if buffer.pos.zero? && result.is_a?(String)
124
- buffer.string
125
- end
126
-
127
- def decrypt_raw(raw)
128
- io = StringIO.new(raw.b)
129
- header = Format.read_header(io)
130
- io.rewind
131
- header.chunked ? @stream_cipher.decrypt_to_io(io).read
132
- : @cipher.decrypt(io)
133
- end
134
-
135
- def cipher_payload?(data)
136
- data.b.start_with?(Format::MAGIC)
137
- end
138
- end
139
- end
140
- end
@@ -1,121 +0,0 @@
1
- module ActiveCipherStorage
2
- # Re-wraps the per-file Data Encryption Key (DEK) stored in encrypted file
3
- # headers without decrypting or re-encrypting the file body.
4
- #
5
- # Why this matters
6
- # ─────────────────
7
- # Every encrypted file stores its DEK in the header, wrapped by the KMS
8
- # master key. When you rotate the master key, only the wrapped DEK in the
9
- # header needs to change — the AES-256-GCM ciphertext body stays untouched.
10
- # This makes rotation O(n blobs) in API calls but O(header size) in data
11
- # transferred per file, not O(file size).
12
- #
13
- # AWS KMS optimisation
14
- # ─────────────────────
15
- # When both providers are AwsKmsProvider, KeyRotation uses KMS ReEncrypt.
16
- # The plaintext DEK never leaves KMS — it is re-wrapped entirely server-side.
17
- # Cross-provider rotations (e.g. EnvProvider → AwsKmsProvider) must briefly
18
- # hold the plaintext DEK in process memory, zeroed immediately after use.
19
- #
20
- # Usage
21
- # ─────
22
- # old_kms = ActiveCipherStorage::Providers::AwsKmsProvider.new(key_id: old_arn)
23
- # new_kms = ActiveCipherStorage::Providers::AwsKmsProvider.new(key_id: new_arn)
24
- #
25
- # ActiveCipherStorage::KeyRotation.rotate(
26
- # old_provider: old_kms,
27
- # new_provider: new_kms,
28
- # service: MyEncryptedStorageService.new
29
- # ) do |blob, result|
30
- # Rails.logger.info "rotated #{blob.key}: #{result[:status]}"
31
- # end
32
- #
33
- module KeyRotation
34
- extend self
35
-
36
- # Rotates every blob associated with old_provider.
37
- # Yields (blob, result_hash) for each blob processed so callers can log
38
- # progress and handle per-blob failures without aborting the batch.
39
- #
40
- # Options:
41
- # dry_run: true — parse headers and validate, but skip the upload step.
42
- def rotate(old_provider:, new_provider:, service:, dry_run: false)
43
- BlobMetadata.blobs_for(old_provider) do |blob|
44
- result = rotate_blob(blob, old_provider: old_provider,
45
- new_provider: new_provider,
46
- service: service,
47
- dry_run: dry_run)
48
- yield blob, result if block_given?
49
- end
50
- end
51
-
52
- # Rotates a single blob. Returns { status: :rotated | :skipped | :failed, ... }.
53
- def rotate_blob(blob, old_provider:, new_provider:, service:, dry_run: false)
54
- encrypted = service.download_raw(blob.key)
55
-
56
- unless Format::MAGIC == encrypted.b[0, 4]
57
- return { status: :skipped, reason: "not an encrypted blob" }
58
- end
59
-
60
- new_payload = rewrite_dek(encrypted, old_provider: old_provider, new_provider: new_provider)
61
-
62
- unless dry_run
63
- service.upload_raw(blob.key, StringIO.new(new_payload))
64
- BlobMetadata.update_after_rotation(blob.key, new_provider)
65
- end
66
-
67
- { status: dry_run ? :validated : :rotated }
68
- rescue => e
69
- { status: :failed, error: e.message }
70
- end
71
-
72
- # Rewrites the encrypted DEK inside an encrypted payload's header.
73
- # The IV, ciphertext, and auth tag(s) are copied byte-for-byte unchanged.
74
- def rewrite_dek(encrypted_data, old_provider:, new_provider:)
75
- io = StringIO.new(encrypted_data.b)
76
- header = Format.read_header(io)
77
- body_offset = io.pos
78
-
79
- new_encrypted_dek = re_wrap_dek(
80
- header.encrypted_dek,
81
- old_provider: old_provider,
82
- new_provider: new_provider
83
- )
84
-
85
- out = StringIO.new("".b)
86
- Format.write_header(out, Format::Header.new(
87
- version: header.version,
88
- algorithm: header.algorithm,
89
- chunked: header.chunked,
90
- chunk_size: header.chunk_size,
91
- provider_id: new_provider.provider_id,
92
- encrypted_dek: new_encrypted_dek
93
- ))
94
- out.write(encrypted_data.b[body_offset..])
95
- out.string
96
- end
97
-
98
- private
99
-
100
- # Chooses the optimal re-wrap strategy:
101
- # - Both AWS KMS → ReEncrypt (plaintext DEK stays in KMS)
102
- # - Otherwise → decrypt with old, wrap with new (plaintext DEK in memory)
103
- def re_wrap_dek(encrypted_dek, old_provider:, new_provider:)
104
- if old_provider.is_a?(Providers::AwsKmsProvider) &&
105
- new_provider.is_a?(Providers::AwsKmsProvider)
106
- return old_provider.rotate_data_key(encrypted_dek,
107
- destination_key_id: new_provider.key_id)
108
- end
109
-
110
- plaintext_dek = old_provider.decrypt_data_key(encrypted_dek)
111
- new_provider.wrap_data_key(plaintext_dek)
112
- ensure
113
- zero_bytes!(plaintext_dek) if defined?(plaintext_dek)
114
- end
115
-
116
- def zero_bytes!(str)
117
- return unless str.is_a?(String)
118
- str.bytesize.times { |i| str.setbyte(i, 0) }
119
- end
120
- end
121
- end