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,55 @@
1
+ require_relative "lib/active_cipher_storage/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "active_cipher_storage"
5
+ spec.version = ActiveCipherStorage::VERSION
6
+ spec.authors = ["Jaspreet Singh"]
7
+ spec.email = ["codebyjass@users.noreply.github.com"]
8
+
9
+ spec.summary = "Transparent file encryption for Active Storage and S3 with pluggable KMS providers"
10
+ spec.description = <<~DESC
11
+ active_cipher_storage provides AES-256-GCM envelope encryption for files stored
12
+ via Rails Active Storage or directly via the AWS S3 SDK. Key management is
13
+ delegated to pluggable KMS providers: environment-variable keys, AWS KMS,
14
+ or any custom provider implementing the base interface.
15
+ DESC
16
+
17
+ spec.homepage = "https://github.com/codebyjass/active-cipher-storage"
18
+ spec.license = "MIT"
19
+ spec.required_ruby_version = ">= 3.2"
20
+
21
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
22
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
23
+ spec.metadata["documentation_uri"] = spec.homepage
24
+ spec.metadata["homepage_uri"] = spec.homepage
25
+ spec.metadata["rubygems_mfa_required"] = "true"
26
+ spec.metadata["source_code_uri"] = spec.homepage
27
+
28
+ spec.files = Dir[
29
+ "lib/**/*.rb",
30
+ "CHANGELOG.md",
31
+ "CONTRIBUTING.md",
32
+ "LICENSE",
33
+ "README.md",
34
+ "SECURITY.md",
35
+ "active_cipher_storage.gemspec"
36
+ ]
37
+
38
+ spec.require_paths = ["lib"]
39
+
40
+ # Core — no runtime dep on Rails or AWS
41
+ spec.add_dependency "concurrent-ruby", "~> 1.2"
42
+
43
+ # Optional integrations — loaded only when the relevant adapter is used
44
+ spec.add_development_dependency "activestorage", ">= 7.0", "< 9.0"
45
+ spec.add_development_dependency "aws-sdk-kms", "~> 1.0"
46
+ spec.add_development_dependency "aws-sdk-s3", "~> 1.0"
47
+
48
+ # Dev/test
49
+ spec.add_development_dependency "rspec", "~> 3.12"
50
+ spec.add_development_dependency "rspec-mocks", "~> 3.12"
51
+ spec.add_development_dependency "rubocop", "~> 1.0"
52
+ spec.add_development_dependency "simplecov", "~> 0.22"
53
+ spec.add_development_dependency "faker", "~> 3.0"
54
+ spec.add_development_dependency "rake", "~> 13.0"
55
+ end
@@ -0,0 +1,129 @@
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
+ @inner.upload(key, encrypt_io(io),
37
+ checksum: nil, # checksum is over plaintext; skip for ciphertext
38
+ content_type: "application/octet-stream",
39
+ filename: filename,
40
+ disposition: disposition,
41
+ custom_metadata: custom_metadata)
42
+
43
+ BlobMetadata.write(key, ActiveCipherStorage.configuration.provider)
44
+ end
45
+
46
+ def download(key, &block)
47
+ raw = collect_download(key)
48
+
49
+ # Legacy plaintext blob — no magic header present.
50
+ return (block ? yield(raw) : raw) unless cipher_payload?(raw)
51
+
52
+ plaintext = decrypt_raw(raw)
53
+ block ? yield(plaintext) : plaintext
54
+ end
55
+
56
+ def download_chunk(key, range)
57
+ download(key).b[range]
58
+ end
59
+
60
+ # Used by KeyRotation to fetch raw ciphertext without decrypting.
61
+ def download_raw(key)
62
+ collect_download(key)
63
+ end
64
+
65
+ # Used by KeyRotation to overwrite a blob's bytes without re-encrypting.
66
+ def upload_raw(key, io)
67
+ @inner.upload(key, io, content_type: "application/octet-stream")
68
+ end
69
+
70
+ # Re-wraps the DEK in a single blob's header under new_provider without
71
+ # decrypting or re-encrypting the file body.
72
+ def rekey(key, old_provider:, new_provider:)
73
+ KeyRotation.rotate_blob(
74
+ BlobRef.new(key),
75
+ old_provider: old_provider,
76
+ new_provider: new_provider,
77
+ service: self
78
+ )
79
+ end
80
+
81
+ def delete(key) = @inner.delete(key)
82
+ def delete_prefixed(pfx) = @inner.delete_prefixed(pfx)
83
+ def exist?(key) = @inner.exist?(key)
84
+
85
+ def url(key, expires_in:, filename:, content_type:, disposition:, **)
86
+ @inner.url(key, expires_in: expires_in, filename: filename,
87
+ content_type: content_type, disposition: disposition)
88
+ end
89
+
90
+ def url_for_direct_upload(*)
91
+ raise Errors::UnsupportedOperation,
92
+ "Direct uploads bypass encryption — use server-side upload instead"
93
+ end
94
+
95
+ def headers_for_direct_upload(*) = {}
96
+
97
+ private
98
+
99
+ def encrypt_io(io)
100
+ config = ActiveCipherStorage.configuration
101
+ if io.respond_to?(:size) && io.size && io.size > config.chunk_size
102
+ @stream_cipher.encrypt_to_io(io)
103
+ else
104
+ StringIO.new(@cipher.encrypt(io))
105
+ end
106
+ end
107
+
108
+ def collect_download(key)
109
+ buffer = StringIO.new("".b)
110
+ result = @inner.download(key) { |chunk| buffer.write(chunk) }
111
+ # Some services return data instead of yielding; handle both.
112
+ buffer.write(result.b) if buffer.pos.zero? && result.is_a?(String)
113
+ buffer.string
114
+ end
115
+
116
+ def decrypt_raw(raw)
117
+ io = StringIO.new(raw.b)
118
+ header = Format.read_header(io)
119
+ io.rewind
120
+ header.chunked ? @stream_cipher.decrypt_to_io(io).read
121
+ : @cipher.decrypt(io)
122
+ end
123
+
124
+ def cipher_payload?(data)
125
+ data.b.start_with?(Format::MAGIC)
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,252 @@
1
+ require "stringio"
2
+
3
+ module ActiveCipherStorage
4
+ module Adapters
5
+ class S3Adapter
6
+ include KeyUtils
7
+
8
+ DEFAULT_MULTIPART_THRESHOLD = 100 * 1024 * 1024
9
+
10
+ def initialize(bucket:, region: nil, multipart_threshold: DEFAULT_MULTIPART_THRESHOLD,
11
+ s3_client: nil, config: nil)
12
+ @bucket = bucket
13
+ @region = region
14
+ @multipart_threshold = multipart_threshold
15
+ @client_override = s3_client
16
+ @config = config || ActiveCipherStorage.configuration
17
+ @config.validate!
18
+ end
19
+
20
+ def put_encrypted(key, io, **options)
21
+ large_file?(io) ? multipart_put(key, io, **options) : single_put(key, io, **options)
22
+ end
23
+
24
+ def get_decrypted(key)
25
+ resp = s3.get_object(bucket: @bucket, key: key)
26
+ decrypt_io(StringIO.new(resp.body.read.b))
27
+ end
28
+
29
+ # Streams decrypted plaintext from S3 without buffering the whole object.
30
+ # Yields each decrypted plaintext chunk as it becomes available.
31
+ # Safe for multi-gigabyte files: memory usage is bounded by chunk_size.
32
+ def stream_decrypted(key, &block)
33
+ raise ArgumentError, "stream_decrypted requires a block" unless block_given?
34
+
35
+ decryptor = StreamingDecryptor.new(@config)
36
+ s3.get_object(bucket: @bucket, key: key) do |s3_chunk|
37
+ decryptor.push(s3_chunk.b, &block)
38
+ end
39
+ decryptor.finish!
40
+ end
41
+
42
+ def presigned_url(key, expires_in: 3600)
43
+ Aws::S3::Presigner.new(client: s3)
44
+ .presigned_url(:get_object, bucket: @bucket, key: key,
45
+ expires_in: expires_in)
46
+ end
47
+
48
+ def exist?(key)
49
+ s3.head_object(bucket: @bucket, key: key)
50
+ true
51
+ rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
52
+ false
53
+ end
54
+
55
+ def delete(key)
56
+ s3.delete_object(bucket: @bucket, key: key)
57
+ end
58
+
59
+ private
60
+
61
+ def single_put(key, io, **options)
62
+ s3.put_object(bucket: @bucket, key: key,
63
+ body: Cipher.new(@config).encrypt(io),
64
+ **upload_options(options))
65
+ end
66
+
67
+ def multipart_put(key, io, **options)
68
+ require "aws-sdk-s3"
69
+ upload_id = s3.create_multipart_upload(bucket: @bucket, key: key,
70
+ **upload_options(options)).upload_id
71
+ parts = stream_multipart_parts(key, io, upload_id)
72
+ s3.complete_multipart_upload(bucket: @bucket, key: key, upload_id: upload_id,
73
+ multipart_upload: { parts: parts })
74
+ rescue StandardError
75
+ abort_multipart_upload(key, upload_id)
76
+ raise
77
+ end
78
+
79
+ def stream_multipart_parts(key, input_io, upload_id)
80
+ dek_bundle = @config.provider.generate_data_key
81
+ plaintext_dek = dek_bundle.fetch(:plaintext_key)
82
+ parts = []
83
+ part_number = 0
84
+
85
+ # Buffer header + chunks together. S3 requires parts >= 5 MiB except the
86
+ # last, so we flush only when the buffer reaches chunk_size or at EOF.
87
+ buffer = StringIO.new("".b)
88
+ Format.write_header(buffer, Format::Header.new(
89
+ version: Format::VERSION,
90
+ algorithm: Format::ALGO_AES256GCM,
91
+ chunked: true,
92
+ chunk_size: @config.chunk_size,
93
+ provider_id: @config.provider.provider_id,
94
+ encrypted_dek: dek_bundle.fetch(:encrypted_key)
95
+ ))
96
+
97
+ seq = 0
98
+ done = false
99
+ until done
100
+ chunk = input_io.read(@config.chunk_size) || "".b
101
+ done = chunk.bytesize < @config.chunk_size
102
+ seq += 1
103
+ frame_seq = done ? Format::FINAL_SEQ : seq
104
+ iv = SecureRandom.random_bytes(Format::IV_SIZE)
105
+
106
+ c = build_chunk_cipher(plaintext_dek, iv, frame_seq)
107
+ ct = chunk.empty? ? c.final : (c.update(chunk.b) + c.final)
108
+ Format.write_chunk(buffer, seq: frame_seq, iv: iv, ciphertext: ct, auth_tag: c.auth_tag)
109
+
110
+ next unless buffer.pos >= @config.chunk_size || done
111
+
112
+ buffer.rewind
113
+ part_number += 1
114
+ etag = s3.upload_part(bucket: @bucket, key: key, upload_id: upload_id,
115
+ part_number: part_number, body: buffer.read).etag
116
+ parts << { part_number: part_number, etag: etag }
117
+ buffer = StringIO.new("".b)
118
+ end
119
+
120
+ parts
121
+ ensure
122
+ zero_bytes!(plaintext_dek)
123
+ end
124
+
125
+ def decrypt_io(io)
126
+ header = Format.read_header(io)
127
+ io.rewind
128
+ header.chunked ? StreamCipher.new(@config).decrypt_to_io(io)
129
+ : StringIO.new(Cipher.new(@config).decrypt(io))
130
+ end
131
+
132
+ def large_file?(io)
133
+ size = io.respond_to?(:size) ? io.size : nil
134
+ size&.> @multipart_threshold
135
+ end
136
+
137
+ def upload_options(opts)
138
+ { content_type: "application/octet-stream" }.merge(opts.slice(:metadata, :tagging))
139
+ end
140
+
141
+ def build_chunk_cipher(key, iv, seq)
142
+ c = OpenSSL::Cipher.new(Cipher::OPENSSL_ALGO)
143
+ c.encrypt
144
+ c.key = key
145
+ c.iv = iv
146
+ c.auth_data = [seq].pack("N")
147
+ c
148
+ end
149
+
150
+ def s3
151
+ @s3 ||= begin
152
+ require "aws-sdk-s3"
153
+ @client_override || Aws::S3::Client.new(**{ region: @region }.compact)
154
+ end
155
+ rescue LoadError
156
+ raise Errors::ProviderError, "aws-sdk-s3 is required: add it to your Gemfile"
157
+ end
158
+
159
+ def abort_multipart_upload(key, upload_id)
160
+ return unless upload_id
161
+
162
+ s3.abort_multipart_upload(bucket: @bucket, key: key, upload_id: upload_id)
163
+ rescue StandardError => abort_error
164
+ @config.logger.warn(
165
+ "[ActiveCipherStorage] Could not abort multipart upload #{upload_id}: #{abort_error.message}"
166
+ )
167
+ end
168
+
169
+ # Accumulates bytes from a streaming S3 response, parses ACS frames as
170
+ # they arrive, and yields each decrypted plaintext chunk.
171
+ # Frame layout: seq(4) + iv(12) + ct_len(4) + ciphertext(ct_len) + auth_tag(16).
172
+ class StreamingDecryptor
173
+ include KeyUtils
174
+
175
+ FRAME_PREFIX_SIZE = 4 + Format::IV_SIZE + 4 # 20 bytes to determine ct_len
176
+
177
+ def initialize(config)
178
+ @config = config
179
+ @buffer = "".b
180
+ @dek = nil
181
+ @header_done = false
182
+ @done = false
183
+ end
184
+
185
+ def push(bytes, &block)
186
+ return if @done
187
+ @buffer += bytes.b
188
+ try_parse_header unless @header_done
189
+ drain_frames(&block) if @header_done
190
+ end
191
+
192
+ def finish!
193
+ raise Errors::InvalidFormat, "Stream ended before final frame" unless @done
194
+ ensure
195
+ zero_bytes!(@dek)
196
+ end
197
+
198
+ private
199
+
200
+ def try_parse_header
201
+ return if @buffer.bytesize < Format::MAGIC.bytesize
202
+
203
+ unless @buffer.start_with?(Format::MAGIC)
204
+ raise Errors::InvalidFormat, "Invalid magic bytes"
205
+ end
206
+
207
+ io = StringIO.new(@buffer)
208
+ header = Format.read_header(io)
209
+ raise Errors::InvalidFormat, "Payload is not chunked; use #get_decrypted" unless header.chunked
210
+
211
+ @dek = @config.provider.decrypt_data_key(header.encrypted_dek)
212
+ @buffer = (@buffer.byteslice(io.pos..) || "".b).b
213
+ @header_done = true
214
+ rescue Errors::InvalidFormat => e
215
+ raise unless e.message.start_with?("Unexpected end of stream") && @buffer.bytesize <= 8192
216
+
217
+ # Need more bytes; keep buffering.
218
+ end
219
+
220
+ def drain_frames(&block)
221
+ until @done
222
+ break if @buffer.bytesize < FRAME_PREFIX_SIZE
223
+ ct_len = @buffer.byteslice(16, 4).unpack1("N")
224
+ frame_size = FRAME_PREFIX_SIZE + ct_len + Format::AUTH_TAG_SIZE
225
+ break if @buffer.bytesize < frame_size
226
+
227
+ frame = Format.read_chunk(StringIO.new(@buffer.byteslice(0, frame_size)))
228
+ @buffer = (@buffer.byteslice(frame_size..) || "".b).b
229
+
230
+ plaintext = decrypt_frame(frame)
231
+ block.call(plaintext) unless plaintext.empty?
232
+ @done = (frame[:seq] == Format::FINAL_SEQ)
233
+ end
234
+ end
235
+
236
+ def decrypt_frame(frame)
237
+ c = OpenSSL::Cipher.new(Cipher::OPENSSL_ALGO)
238
+ c.decrypt
239
+ c.key = @dek
240
+ c.iv = frame[:iv]
241
+ c.auth_tag = frame[:auth_tag]
242
+ c.auth_data = [frame[:seq]].pack("N")
243
+ ct = frame[:ciphertext]
244
+ ct.empty? ? c.final : (c.update(ct) + c.final)
245
+ rescue OpenSSL::Cipher::CipherError
246
+ raise Errors::DecryptionError,
247
+ "Authentication failed on chunk seq=#{frame[:seq]} — data may be tampered"
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,84 @@
1
+ module ActiveCipherStorage
2
+ # Reads and writes encryption metadata on ActiveStorage::Blob records.
3
+ #
4
+ # Written fields (all stored under the blob's existing `metadata` JSON column):
5
+ # encrypted => true
6
+ # cipher_version => Integer (Format::VERSION)
7
+ # provider_id => String (e.g. "aws_kms", "env")
8
+ # kms_key_id => String (CMK ARN, env-var name, or nil)
9
+ #
10
+ # These are for operational visibility — rotation queries, auditing,
11
+ # backward-compat detection. The encrypted file header is always the
12
+ # authoritative source for decryption.
13
+ module BlobMetadata
14
+ def self.write(storage_key, provider)
15
+ return unless active_storage_available?
16
+
17
+ blob = ActiveStorage::Blob.find_by(key: storage_key)
18
+ return unless blob
19
+
20
+ blob.update_columns(
21
+ metadata: blob.metadata.merge(
22
+ "encrypted" => true,
23
+ "cipher_version" => Format::VERSION,
24
+ "provider_id" => provider.provider_id,
25
+ "kms_key_id" => provider.key_id
26
+ ).compact
27
+ )
28
+ rescue => e
29
+ ActiveCipherStorage.configuration.logger.warn(
30
+ "[ActiveCipherStorage] Could not write blob metadata for #{storage_key}: #{e.message}"
31
+ )
32
+ end
33
+
34
+ def self.update_after_rotation(storage_key, new_provider)
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
+ "provider_id" => new_provider.provider_id,
43
+ "kms_key_id" => new_provider.key_id
44
+ ).compact
45
+ )
46
+ rescue => e
47
+ ActiveCipherStorage.configuration.logger.warn(
48
+ "[ActiveCipherStorage] Could not update rotation metadata for #{storage_key}: #{e.message}"
49
+ )
50
+ end
51
+
52
+ # Returns the metadata hash for a blob, or nil if AR is unavailable.
53
+ def self.for(storage_key)
54
+ return nil unless active_storage_available?
55
+ ActiveStorage::Blob.find_by(key: storage_key)&.metadata
56
+ end
57
+
58
+ # Finds all blobs whose metadata matches the given provider.
59
+ # Iterates in batches to avoid loading all blobs into memory.
60
+ # Yields each matching blob.
61
+ #
62
+ # For large tables, add a DB-level index on `metadata->>'kms_key_id'`
63
+ # and narrow the scope before passing to this method.
64
+ def self.blobs_for(provider)
65
+ return enum_for(:blobs_for, provider) unless block_given?
66
+ return unless active_storage_available?
67
+
68
+ ActiveStorage::Blob.find_each do |blob|
69
+ meta = blob.metadata
70
+ next unless meta["encrypted"] == true
71
+ next unless meta["provider_id"] == provider.provider_id
72
+ next if provider.key_id && meta["kms_key_id"] != provider.key_id
73
+
74
+ yield blob
75
+ end
76
+ end
77
+
78
+ private_class_method def self.active_storage_available?
79
+ defined?(ActiveStorage::Blob) && ActiveStorage::Blob.table_exists?
80
+ rescue
81
+ false
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,97 @@
1
+ require "openssl"
2
+ require "securerandom"
3
+ require "stringio"
4
+
5
+ module ActiveCipherStorage
6
+ class Cipher
7
+ include KeyUtils
8
+
9
+ OPENSSL_ALGO = "aes-256-gcm"
10
+ KEY_SIZE = 32
11
+
12
+ def initialize(config = ActiveCipherStorage.configuration)
13
+ config.validate!
14
+ @config = config
15
+ @provider = config.provider
16
+ end
17
+
18
+ def encrypt(io)
19
+ plaintext = io.read.b
20
+ dek_bundle = @provider.generate_data_key
21
+ key = dek_bundle.fetch(:plaintext_key)
22
+ iv = SecureRandom.random_bytes(Format::IV_SIZE)
23
+
24
+ c = build_cipher(:encrypt, key, iv)
25
+ ciphertext = c.update(plaintext) + c.final
26
+ auth_tag = c.auth_tag
27
+
28
+ out = StringIO.new("".b)
29
+ Format.write_header(out, header(dek_bundle, chunked: false))
30
+ out.write(iv)
31
+ out.write(ciphertext)
32
+ out.write(auth_tag)
33
+ out.string
34
+ ensure
35
+ zero_bytes!(key)
36
+ zero_bytes!(plaintext)
37
+ end
38
+
39
+ def decrypt(encrypted_data)
40
+ io = to_binary_io(encrypted_data)
41
+ header = Format.read_header(io)
42
+ raise Errors::InvalidFormat, "Payload is chunked; use StreamCipher#decrypt" if header.chunked
43
+
44
+ key = @provider.decrypt_data_key(header.encrypted_dek)
45
+ iv = io.read(Format::IV_SIZE)
46
+ tail = drain(io)
47
+ ciphertext = tail.byteslice(0, tail.bytesize - Format::AUTH_TAG_SIZE)
48
+ auth_tag = tail.byteslice(-Format::AUTH_TAG_SIZE, Format::AUTH_TAG_SIZE)
49
+
50
+ c = build_cipher(:decrypt, key, iv, auth_tag)
51
+ c.update(ciphertext) + c.final
52
+ rescue OpenSSL::Cipher::CipherError
53
+ raise Errors::DecryptionError, "Authentication failed — ciphertext may be tampered or the key is wrong"
54
+ ensure
55
+ zero_bytes!(key)
56
+ end
57
+
58
+ private
59
+
60
+ def build_cipher(mode, key, iv, auth_tag = nil)
61
+ c = OpenSSL::Cipher.new(OPENSSL_ALGO)
62
+ mode == :encrypt ? c.encrypt : c.decrypt
63
+ c.key = key
64
+ c.iv = iv
65
+ c.auth_tag = auth_tag if auth_tag
66
+ c.auth_data = "" # required by GCM even when empty
67
+ c
68
+ end
69
+
70
+ def header(dek_bundle, chunked:)
71
+ Format::Header.new(
72
+ version: Format::VERSION,
73
+ algorithm: Format::ALGO_AES256GCM,
74
+ chunked: chunked,
75
+ chunk_size: 0,
76
+ provider_id: @provider.provider_id,
77
+ encrypted_dek: dek_bundle.fetch(:encrypted_key)
78
+ )
79
+ end
80
+
81
+ def to_binary_io(data)
82
+ case data
83
+ when StringIO then data.tap(&:rewind)
84
+ when IO then data
85
+ else StringIO.new(data.to_s.b)
86
+ end
87
+ end
88
+
89
+ def drain(io)
90
+ buf = "".b
91
+ while (chunk = io.read(65_536))
92
+ buf << chunk
93
+ end
94
+ buf
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,57 @@
1
+ require "logger"
2
+
3
+ module ActiveCipherStorage
4
+ class Configuration
5
+ # Supported algorithm identifiers.
6
+ ALGORITHMS = %w[aes-256-gcm].freeze
7
+
8
+ # Bytes per plaintext chunk in streaming mode (default 5 MiB — matches the
9
+ # minimum S3 multipart part size, so each chunk maps to exactly one part).
10
+ DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024
11
+
12
+ attr_reader :provider
13
+ attr_accessor :algorithm, :chunk_size, :logger
14
+
15
+ def initialize
16
+ @algorithm = "aes-256-gcm"
17
+ @chunk_size = DEFAULT_CHUNK_SIZE
18
+ @provider = nil
19
+ @logger = Logger.new($stdout, level: Logger::WARN)
20
+ end
21
+
22
+ # Accept a provider instance or a symbol shorthand (:env, :aws_kms).
23
+ def provider=(value)
24
+ @provider = case value
25
+ when Symbol then resolve_provider(value)
26
+ when Providers::Base then value
27
+ else
28
+ raise ArgumentError,
29
+ "provider must be a Providers::Base instance or " \
30
+ "one of :env, :aws_kms — got #{value.inspect}"
31
+ end
32
+ end
33
+
34
+ def validate!
35
+ raise ProviderError, "No KMS provider configured. " \
36
+ "Set ActiveCipherStorage.configuration.provider." unless @provider
37
+
38
+ unless ALGORITHMS.include?(@algorithm)
39
+ raise ArgumentError, "Unsupported algorithm: #{@algorithm.inspect}. " \
40
+ "Supported: #{ALGORITHMS.join(', ')}"
41
+ end
42
+
43
+ raise ArgumentError, "chunk_size must be positive" unless @chunk_size.positive?
44
+ end
45
+
46
+ private
47
+
48
+ def resolve_provider(sym)
49
+ case sym
50
+ when :env then Providers::EnvProvider.new
51
+ when :aws_kms then Providers::AwsKmsProvider.new
52
+ else
53
+ raise ArgumentError, "Unknown provider shorthand: #{sym.inspect}"
54
+ end
55
+ end
56
+ end
57
+ end