active_cipher_storage 1.0.3 → 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.
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.3
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