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.
@@ -0,0 +1,122 @@
1
+ require "openssl"
2
+ require "securerandom"
3
+ require "base64"
4
+
5
+ module ActiveCipherStorage
6
+ module Providers
7
+ class EnvProvider < Base
8
+ include KeyUtils
9
+
10
+ PROVIDER_ID = "env"
11
+ WRAP_ALGO = "aes-256-gcm"
12
+ MASTER_KEY_SIZE = 32
13
+ WRAP_IV_SIZE = 12
14
+ WRAP_TAG_SIZE = 16
15
+
16
+ def initialize(env_var: "ACTIVE_CIPHER_MASTER_KEY", old_env_var: nil)
17
+ @env_var = env_var
18
+ @old_env_var = old_env_var
19
+ end
20
+
21
+ def provider_id = PROVIDER_ID
22
+ def key_id = @env_var
23
+
24
+ def generate_data_key
25
+ master = read_master_key(@env_var)
26
+ dek = SecureRandom.random_bytes(Cipher::KEY_SIZE)
27
+ { plaintext_key: dek, encrypted_key: wrap_key(dek, master) }
28
+ ensure
29
+ zero_bytes!(master)
30
+ end
31
+
32
+ def decrypt_data_key(encrypted_key)
33
+ master = read_master_key(@env_var)
34
+ unwrap_key(encrypted_key, master)
35
+ ensure
36
+ zero_bytes!(master)
37
+ end
38
+
39
+ def wrap_data_key(plaintext_dek)
40
+ master = read_master_key(@env_var)
41
+ wrap_key(plaintext_dek, master)
42
+ ensure
43
+ zero_bytes!(master)
44
+ end
45
+
46
+ def rotate_data_key(encrypted_key, old_provider: nil)
47
+ source = old_provider || begin
48
+ raise Errors::UnsupportedOperation,
49
+ "Supply :old_provider to rotate via EnvProvider" unless @old_env_var
50
+ EnvProvider.new(env_var: @old_env_var)
51
+ end
52
+
53
+ plaintext_dek = source.decrypt_data_key(encrypted_key)
54
+ new_master = read_master_key(@env_var)
55
+ wrap_key(plaintext_dek, new_master)
56
+ ensure
57
+ zero_bytes!(plaintext_dek)
58
+ zero_bytes!(new_master)
59
+ end
60
+
61
+ private
62
+
63
+ # Wrapped DEK: [12 IV][32 ciphertext][16 auth-tag] = 60 bytes
64
+ def wrap_key(dek, master)
65
+ iv = SecureRandom.random_bytes(WRAP_IV_SIZE)
66
+ c = new_cipher(:encrypt, master, iv)
67
+ ct = c.update(dek) + c.final
68
+ iv + ct + c.auth_tag
69
+ end
70
+
71
+ def unwrap_key(wrapped, master)
72
+ expected = WRAP_IV_SIZE + Cipher::KEY_SIZE + WRAP_TAG_SIZE
73
+ unless wrapped.bytesize == expected
74
+ raise Errors::InvalidFormat, "Wrapped DEK has unexpected size #{wrapped.bytesize}"
75
+ end
76
+
77
+ iv = wrapped.byteslice(0, WRAP_IV_SIZE)
78
+ ct = wrapped.byteslice(WRAP_IV_SIZE, Cipher::KEY_SIZE)
79
+ tag = wrapped.byteslice(-WRAP_TAG_SIZE, WRAP_TAG_SIZE)
80
+
81
+ new_cipher(:decrypt, master, iv, tag).then { |c| c.update(ct) + c.final }
82
+ rescue OpenSSL::Cipher::CipherError
83
+ raise Errors::KeyManagementError,
84
+ "Master-key authentication failed — wrong key or tampered DEK"
85
+ end
86
+
87
+ def new_cipher(mode, key, iv, auth_tag = nil)
88
+ c = OpenSSL::Cipher.new(WRAP_ALGO)
89
+ mode == :encrypt ? c.encrypt : c.decrypt
90
+ c.key = key
91
+ c.iv = iv
92
+ c.auth_tag = auth_tag if auth_tag
93
+ c.auth_data = ""
94
+ c
95
+ end
96
+
97
+ def read_master_key(var_name)
98
+ encoded = ENV.fetch(var_name) do
99
+ raise Errors::ProviderError,
100
+ "Environment variable #{var_name.inspect} is not set. " \
101
+ "Generate one with: ruby -rsecurerandom -e " \
102
+ "'puts Base64.strict_encode64(SecureRandom.bytes(32))'"
103
+ end
104
+
105
+ key = begin
106
+ Base64.strict_decode64(encoded)
107
+ rescue ArgumentError
108
+ raise Errors::ProviderError,
109
+ "#{var_name} must be Base64-encoded (strict, no line breaks)"
110
+ end
111
+
112
+ unless key.bytesize == MASTER_KEY_SIZE
113
+ raise Errors::ProviderError,
114
+ "#{var_name} must decode to exactly #{MASTER_KEY_SIZE} bytes " \
115
+ "(got #{key.bytesize})"
116
+ end
117
+
118
+ key
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,102 @@
1
+ require "openssl"
2
+ require "securerandom"
3
+
4
+ module ActiveCipherStorage
5
+ class StreamCipher
6
+ include KeyUtils
7
+
8
+ def initialize(config = ActiveCipherStorage.configuration)
9
+ config.validate!
10
+ @config = config
11
+ @provider = config.provider
12
+ @chunk_size = config.chunk_size
13
+ end
14
+
15
+ def encrypt(input_io, output_io)
16
+ dek_bundle = @provider.generate_data_key
17
+ key = dek_bundle.fetch(:plaintext_key)
18
+
19
+ Format.write_header(output_io, Format::Header.new(
20
+ version: Format::VERSION,
21
+ algorithm: Format::ALGO_AES256GCM,
22
+ chunked: true,
23
+ chunk_size: @chunk_size,
24
+ provider_id: @provider.provider_id,
25
+ encrypted_dek: dek_bundle.fetch(:encrypted_key)
26
+ ))
27
+
28
+ seq = 0
29
+ done = false
30
+ until done
31
+ plaintext = input_io.read(@chunk_size) || "".b
32
+ done = plaintext.bytesize < @chunk_size
33
+ seq += 1
34
+ frame_seq = done ? Format::FINAL_SEQ : seq
35
+ iv = SecureRandom.random_bytes(Format::IV_SIZE)
36
+ ct, tag = encrypt_chunk(plaintext, key, iv, frame_seq)
37
+ Format.write_chunk(output_io, seq: frame_seq, iv: iv, ciphertext: ct, auth_tag: tag)
38
+ end
39
+
40
+ seq
41
+ ensure
42
+ zero_bytes!(key)
43
+ end
44
+
45
+ def decrypt(input_io, output_io)
46
+ header = Format.read_header(input_io)
47
+ raise Errors::InvalidFormat, "Payload is not chunked; use Cipher#decrypt" unless header.chunked
48
+
49
+ key = @provider.decrypt_data_key(header.encrypted_dek)
50
+ loop do
51
+ frame = Format.read_chunk(input_io)
52
+ raise Errors::InvalidFormat, "Unexpected end of stream — missing final frame" if frame.nil?
53
+
54
+ output_io.write(decrypt_chunk(frame[:ciphertext], key, frame[:iv], frame[:auth_tag], frame[:seq]))
55
+ break if frame[:seq] == Format::FINAL_SEQ
56
+ end
57
+ ensure
58
+ zero_bytes!(key)
59
+ end
60
+
61
+ def encrypt_to_io(io)
62
+ out = StringIO.new("".b)
63
+ encrypt(io, out)
64
+ out.rewind
65
+ out
66
+ end
67
+
68
+ def decrypt_to_io(io)
69
+ out = StringIO.new("".b)
70
+ decrypt(io, out)
71
+ out.rewind
72
+ out
73
+ end
74
+
75
+ private
76
+
77
+ def encrypt_chunk(plaintext, key, iv, seq)
78
+ c = build_cipher(:encrypt, key, iv, nil, seq)
79
+ # OpenSSL raises "data must not be empty" on update(""); call final directly.
80
+ ct = plaintext.empty? ? c.final : (c.update(plaintext.b) + c.final)
81
+ [ct, c.auth_tag]
82
+ end
83
+
84
+ def decrypt_chunk(ciphertext, key, iv, auth_tag, seq)
85
+ c = build_cipher(:decrypt, key, iv, auth_tag, seq)
86
+ ciphertext.empty? ? c.final : (c.update(ciphertext) + c.final)
87
+ rescue OpenSSL::Cipher::CipherError
88
+ raise Errors::DecryptionError,
89
+ "Authentication failed on chunk seq=#{seq} — data may be tampered"
90
+ end
91
+
92
+ def build_cipher(mode, key, iv, auth_tag, seq)
93
+ c = OpenSSL::Cipher.new(Cipher::OPENSSL_ALGO)
94
+ mode == :encrypt ? c.encrypt : c.decrypt
95
+ c.key = key
96
+ c.iv = iv
97
+ c.auth_tag = auth_tag if auth_tag
98
+ c.auth_data = [seq].pack("N") # seq as AAD prevents chunk reordering attacks
99
+ c
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,3 @@
1
+ module ActiveCipherStorage
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,43 @@
1
+ require "openssl"
2
+ require "securerandom"
3
+ require "base64"
4
+ require "stringio"
5
+ require "concurrent"
6
+
7
+ require_relative "active_cipher_storage/version"
8
+ require_relative "active_cipher_storage/errors"
9
+ require_relative "active_cipher_storage/key_utils"
10
+ require_relative "active_cipher_storage/format"
11
+ require_relative "active_cipher_storage/configuration"
12
+ require_relative "active_cipher_storage/providers/base"
13
+ require_relative "active_cipher_storage/providers/env_provider"
14
+ require_relative "active_cipher_storage/providers/aws_kms_provider"
15
+ require_relative "active_cipher_storage/cipher"
16
+ require_relative "active_cipher_storage/stream_cipher"
17
+ require_relative "active_cipher_storage/adapters/s3_adapter"
18
+ require_relative "active_cipher_storage/adapters/active_storage_service"
19
+ require_relative "active_cipher_storage/blob_metadata"
20
+ require_relative "active_cipher_storage/key_rotation"
21
+ require_relative "active_cipher_storage/multipart_upload"
22
+
23
+ # Rails Engine wires the service into ActiveStorage's service registry.
24
+ require_relative "active_cipher_storage/engine" if defined?(Rails)
25
+
26
+ module ActiveCipherStorage
27
+ @config_mutex = Mutex.new
28
+ @configuration = Configuration.new
29
+
30
+ class << self
31
+ def configuration
32
+ @configuration
33
+ end
34
+
35
+ def configure
36
+ @config_mutex.synchronize { yield @configuration }
37
+ end
38
+
39
+ def reset_configuration!
40
+ @config_mutex.synchronize { @configuration = Configuration.new }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,10 @@
1
+ require "active_storage/service"
2
+ require "active_cipher_storage/adapters/active_storage_service"
3
+
4
+ module ActiveStorage
5
+ class Service
6
+ unless const_defined?(:ActiveCipherStorageService, false)
7
+ ActiveCipherStorageService = ::ActiveCipherStorage::Adapters::ActiveStorageService
8
+ end
9
+ end
10
+ end
metadata ADDED
@@ -0,0 +1,224 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_cipher_storage
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Jaspreet Singh
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: concurrent-ruby
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activestorage
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.0'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '9.0'
37
+ type: :development
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '7.0'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '9.0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: aws-sdk-kms
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: aws-sdk-s3
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.0'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rspec
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.12'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.12'
89
+ - !ruby/object:Gem::Dependency
90
+ name: rspec-mocks
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.12'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '3.12'
103
+ - !ruby/object:Gem::Dependency
104
+ name: rubocop
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '1.0'
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '1.0'
117
+ - !ruby/object:Gem::Dependency
118
+ name: simplecov
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '0.22'
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '0.22'
131
+ - !ruby/object:Gem::Dependency
132
+ name: faker
133
+ requirement: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '3.0'
138
+ type: :development
139
+ prerelease: false
140
+ version_requirements: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '3.0'
145
+ - !ruby/object:Gem::Dependency
146
+ name: rake
147
+ requirement: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '13.0'
152
+ type: :development
153
+ prerelease: false
154
+ version_requirements: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: '13.0'
159
+ description: |
160
+ active_cipher_storage provides AES-256-GCM envelope encryption for files stored
161
+ via Rails Active Storage or directly via the AWS S3 SDK. Key management is
162
+ delegated to pluggable KMS providers: environment-variable keys, AWS KMS,
163
+ or any custom provider implementing the base interface.
164
+ email:
165
+ - codebyjass@users.noreply.github.com
166
+ executables: []
167
+ extensions: []
168
+ extra_rdoc_files: []
169
+ files:
170
+ - CHANGELOG.md
171
+ - CONTRIBUTING.md
172
+ - LICENSE
173
+ - README.md
174
+ - SECURITY.md
175
+ - active_cipher_storage.gemspec
176
+ - lib/active_cipher_storage.rb
177
+ - lib/active_cipher_storage/adapters/active_storage_service.rb
178
+ - lib/active_cipher_storage/adapters/s3_adapter.rb
179
+ - lib/active_cipher_storage/blob_metadata.rb
180
+ - lib/active_cipher_storage/cipher.rb
181
+ - lib/active_cipher_storage/configuration.rb
182
+ - lib/active_cipher_storage/engine.rb
183
+ - lib/active_cipher_storage/errors.rb
184
+ - lib/active_cipher_storage/format.rb
185
+ - lib/active_cipher_storage/key_rotation.rb
186
+ - lib/active_cipher_storage/key_utils.rb
187
+ - lib/active_cipher_storage/multipart_upload.rb
188
+ - lib/active_cipher_storage/providers/aws_kms_provider.rb
189
+ - lib/active_cipher_storage/providers/base.rb
190
+ - lib/active_cipher_storage/providers/env_provider.rb
191
+ - lib/active_cipher_storage/stream_cipher.rb
192
+ - lib/active_cipher_storage/version.rb
193
+ - lib/active_storage/service/active_cipher_storage_service.rb
194
+ homepage: https://github.com/codebyjass/active-cipher-storage
195
+ licenses:
196
+ - MIT
197
+ metadata:
198
+ bug_tracker_uri: https://github.com/codebyjass/active-cipher-storage/issues
199
+ changelog_uri: https://github.com/codebyjass/active-cipher-storage/blob/main/CHANGELOG.md
200
+ documentation_uri: https://github.com/codebyjass/active-cipher-storage
201
+ homepage_uri: https://github.com/codebyjass/active-cipher-storage
202
+ rubygems_mfa_required: 'true'
203
+ source_code_uri: https://github.com/codebyjass/active-cipher-storage
204
+ post_install_message:
205
+ rdoc_options: []
206
+ require_paths:
207
+ - lib
208
+ required_ruby_version: !ruby/object:Gem::Requirement
209
+ requirements:
210
+ - - ">="
211
+ - !ruby/object:Gem::Version
212
+ version: '3.2'
213
+ required_rubygems_version: !ruby/object:Gem::Requirement
214
+ requirements:
215
+ - - ">="
216
+ - !ruby/object:Gem::Version
217
+ version: '0'
218
+ requirements: []
219
+ rubygems_version: 3.4.19
220
+ signing_key:
221
+ specification_version: 4
222
+ summary: Transparent file encryption for Active Storage and S3 with pluggable KMS
223
+ providers
224
+ test_files: []