From 49c12c2be7fe6dd66662e84808f2380b34a13ab1 Mon Sep 17 00:00:00 2001 From: Jaspreet Singh Date: Sat, 25 Apr 2026 17:38:51 +0400 Subject: [PATCH 1/2] refactor: use Rails-style configuration Back configuration with ActiveSupport options while keeping the public API stable, and harden plaintext opt-out and streaming integrity paths for release. --- Gemfile.lock | 1 + README.md | 45 ++--------- active_cipher_storage.gemspec | 1 + .../adapters/active_storage_service.rb | 11 +++ .../adapters/s3_adapter.rb | 28 ++++++- lib/active_cipher_storage/blob_metadata.rb | 20 +++++ lib/active_cipher_storage/configuration.rb | 78 ++++++++++++++----- lib/active_cipher_storage/multipart_upload.rb | 9 +++ lib/active_cipher_storage/stream_cipher.rb | 20 +++++ spec/integration/active_storage_spec.rb | 16 ++++ spec/integration/multipart_streaming_spec.rb | 38 +++++++++ spec/integration/s3_adapter_spec.rb | 14 +++- spec/unit/blob_metadata_spec.rb | 24 +++++- spec/unit/configuration_spec.rb | 27 +++++++ spec/unit/multipart_upload_spec.rb | 7 ++ spec/unit/stream_cipher_spec.rb | 32 ++++++++ 16 files changed, 310 insertions(+), 61 deletions(-) create mode 100644 spec/unit/configuration_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index af484cc..bbe46b8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: active_cipher_storage (1.0.0) + activesupport (>= 7.0, < 9.0) concurrent-ruby (~> 1.2) GEM diff --git a/README.md b/README.md index dfb4c9d..e4b70d0 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,6 @@ ActiveCipherStorage supports three upload paths: - **Direct S3 clients** — service objects and non-Rails apps can call `put_encrypted`, `get_decrypted`, and `stream_decrypted`. - **Frontend chunk uploads** — the frontend sends plaintext chunks to your backend; the backend encrypts those chunks and uploads encrypted S3 multipart parts. ---- - ## Contents 1. [How it works](#how-it-works) @@ -36,8 +34,6 @@ ActiveCipherStorage supports three upload paths: 17. [License](#license) 18. [Ruby and Rails compatibility](#ruby-and-rails-compatibility) ---- - ## How it works 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, 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. ---- - ## Installation ```ruby @@ -91,8 +85,6 @@ gem "aws-sdk-s3" bundle install ``` ---- - ## Rails / Active Storage setup ### 1. Configure a KMS provider @@ -113,6 +105,7 @@ ActiveCipherStorage.configure do |config| # Tuning (optional) config.chunk_size = 5 * 1024 * 1024 # 5 MiB per chunk (default) + config.encrypt_uploads = true # set false to store new Active Storage uploads as plaintext end ``` @@ -163,9 +156,9 @@ url = rails_blob_url(user.document) 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. -Direct Active Storage browser uploads are intentionally disabled because they bypass the backend encryption layer. +`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. ---- +Direct Active Storage browser uploads are intentionally disabled because they bypass the backend encryption layer. ## Standalone S3 usage @@ -202,8 +195,6 @@ s3 = ActiveCipherStorage::Adapters::S3Adapter.new( ) ``` ---- - ## Chunked multipart upload 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( **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. ---- - ## Streaming download `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 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. ---- - ## Manual encrypt / decrypt Use `Cipher` (in-memory) or `StreamCipher` (chunked, constant memory): @@ -354,8 +341,6 @@ File.open("large.bin.enc", "rb") do |input| end ``` ---- - ## Blob metadata When using the Rails Active Storage adapter, encryption metadata is automatically written to `ActiveStorage::Blob#metadata` after each upload: @@ -404,8 +389,6 @@ end 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). ---- - ## KMS providers ### Environment-variable provider @@ -477,8 +460,6 @@ The `provider_id` is embedded in every encrypted file. Routing at decrypt time i Implement `rotate_data_key(encrypted_key)` as well if the provider can re-wrap encrypted DEKs without exposing plaintext key material. ---- - ## Key rotation ### AWS KMS automatic rotation @@ -516,8 +497,6 @@ new_provider = ActiveCipherStorage::Providers::EnvProvider.new(env_var: "NEW_KEY new_dek = new_provider.rotate_data_key(encrypted_dek, old_provider: old_provider) ``` ---- - ## Configuration reference ```ruby @@ -532,13 +511,15 @@ ActiveCipherStorage.configure do |config| # Must be >= 5 MiB for S3 multipart uploads (except the last part). config.chunk_size = 5 * 1024 * 1024 + # Controls new Active Storage uploads only. Downloads always auto-detect + # encrypted vs. plaintext payloads by the ACS header. + config.encrypt_uploads = true + # Logger instance. Defaults to STDOUT at WARN level. config.logger = Rails.logger end ``` ---- - ## Encryption format Every encrypted payload is a self-describing binary blob: @@ -575,8 +556,6 @@ CHUNKED PAYLOAD (repeated until final frame) - Auth tag failure raises `DecryptionError` immediately — no partial plaintext is returned. - Unsupported format versions, algorithms, and header flags raise `InvalidFormat` instead of being parsed permissively. ---- - ## Security notes | Risk | Mitigation | @@ -587,8 +566,6 @@ CHUNKED PAYLOAD (repeated until final frame) | Partial-read oracle | `DecryptionError` is always raised from `cipher.final`; no partial plaintext is ever returned. | | Accidental plaintext upload | All upload paths go through the cipher layer; there is no bypass. | ---- - ## Testing ```bash @@ -604,8 +581,6 @@ bundle exec rake spec:integration Integration tests use in-memory fakes for both Active Storage and S3 — no real AWS credentials or S3 bucket required. ---- - ## Contributing Contributions are welcome. Please read `CONTRIBUTING.md` before opening a pull request. @@ -618,22 +593,16 @@ bundle exec rspec Do not commit secrets, credentials, `.env` files, local coverage output, or generated gems. ---- - ## Security reports 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: https://github.com/codebyjass/active-cipher-storage/security/advisories/new ---- - ## License The gem is available as open source under the terms of the MIT License. See `LICENSE`. ---- - ## Ruby and Rails compatibility | | Version | diff --git a/active_cipher_storage.gemspec b/active_cipher_storage.gemspec index 84d8db3..e653f6e 100644 --- a/active_cipher_storage.gemspec +++ b/active_cipher_storage.gemspec @@ -38,6 +38,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] # Core — no runtime dep on Rails or AWS + spec.add_dependency "activesupport", ">= 7.0", "< 9.0" spec.add_dependency "concurrent-ruby", "~> 1.2" # Optional integrations — loaded only when the relevant adapter is used diff --git a/lib/active_cipher_storage/adapters/active_storage_service.rb b/lib/active_cipher_storage/adapters/active_storage_service.rb index 2633a8e..0f2d0b8 100644 --- a/lib/active_cipher_storage/adapters/active_storage_service.rb +++ b/lib/active_cipher_storage/adapters/active_storage_service.rb @@ -33,6 +33,17 @@ def initialize(wrapped_service:, **_kwargs) def upload(key, io, checksum: nil, content_type: nil, filename: nil, disposition: nil, custom_metadata: {}) + unless ActiveCipherStorage.configuration.encrypt_uploads + @inner.upload(key, io, + checksum: checksum, + content_type: content_type, + filename: filename, + disposition: disposition, + custom_metadata: custom_metadata) + BlobMetadata.write_plaintext(key) + return + end + @inner.upload(key, encrypt_io(io), checksum: nil, # checksum is over plaintext; skip for ciphertext content_type: "application/octet-stream", diff --git a/lib/active_cipher_storage/adapters/s3_adapter.rb b/lib/active_cipher_storage/adapters/s3_adapter.rb index a0ac4b2..65a22a2 100644 --- a/lib/active_cipher_storage/adapters/s3_adapter.rb +++ b/lib/active_cipher_storage/adapters/s3_adapter.rb @@ -65,7 +65,7 @@ def single_put(key, io, **options) end def multipart_put(key, io, **options) - require "aws-sdk-s3" + validate_multipart_chunk_size! upload_id = s3.create_multipart_upload(bucket: @bucket, key: key, **upload_options(options)).upload_id parts = stream_multipart_parts(key, io, upload_id) @@ -147,6 +147,14 @@ def build_chunk_cipher(key, iv, seq) c end + def validate_multipart_chunk_size! + min_size = Configuration::MINIMUM_S3_MULTIPART_PART_SIZE + return if @config.chunk_size >= min_size + + raise ArgumentError, + "chunk_size must be at least 5 MiB for S3 multipart uploads" + end + def s3 @s3 ||= begin require "aws-sdk-s3" @@ -180,10 +188,16 @@ def initialize(config) @dek = nil @header_done = false @done = false + @expected_seq = 1 end def push(bytes, &block) - return if @done + if @done + raise Errors::InvalidFormat, "Trailing bytes after final frame" unless bytes.empty? + + return + end + @buffer += bytes.b try_parse_header unless @header_done drain_frames(&block) if @header_done @@ -191,6 +205,7 @@ def push(bytes, &block) def finish! raise Errors::InvalidFormat, "Stream ended before final frame" unless @done + raise Errors::InvalidFormat, "Trailing bytes after final frame" unless @buffer.empty? ensure zero_bytes!(@dek) end @@ -227,12 +242,21 @@ def drain_frames(&block) frame = Format.read_chunk(StringIO.new(@buffer.byteslice(0, frame_size))) @buffer = (@buffer.byteslice(frame_size..) || "".b).b + validate_frame_sequence!(frame[:seq]) plaintext = decrypt_frame(frame) block.call(plaintext) unless plaintext.empty? @done = (frame[:seq] == Format::FINAL_SEQ) + @expected_seq += 1 unless @done end end + def validate_frame_sequence!(seq) + return if [Format::FINAL_SEQ, @expected_seq].include?(seq) + + raise Errors::InvalidFormat, + "Unexpected chunk sequence: expected #{@expected_seq}, got #{seq}" + end + def decrypt_frame(frame) c = OpenSSL::Cipher.new(Cipher::OPENSSL_ALGO) c.decrypt diff --git a/lib/active_cipher_storage/blob_metadata.rb b/lib/active_cipher_storage/blob_metadata.rb index 835c80b..d4ebd50 100644 --- a/lib/active_cipher_storage/blob_metadata.rb +++ b/lib/active_cipher_storage/blob_metadata.rb @@ -31,6 +31,26 @@ def self.write(storage_key, provider) ) end + def self.write_plaintext(storage_key) + return unless active_storage_available? + + blob = ActiveStorage::Blob.find_by(key: storage_key) + return unless blob + + blob.update_columns( + metadata: blob.metadata.merge( + "encrypted" => false, + "cipher_version" => nil, + "provider_id" => nil, + "kms_key_id" => nil + ).compact + ) + rescue => e + ActiveCipherStorage.configuration.logger.warn( + "[ActiveCipherStorage] Could not write plaintext blob metadata for #{storage_key}: #{e.message}" + ) + end + def self.update_after_rotation(storage_key, new_provider) return unless active_storage_available? diff --git a/lib/active_cipher_storage/configuration.rb b/lib/active_cipher_storage/configuration.rb index 84a62f4..1f500e1 100644 --- a/lib/active_cipher_storage/configuration.rb +++ b/lib/active_cipher_storage/configuration.rb @@ -1,4 +1,5 @@ require "logger" +require "active_support/ordered_options" module ActiveCipherStorage class Configuration @@ -7,40 +8,81 @@ class Configuration # Bytes per plaintext chunk in streaming mode (default 5 MiB — matches the # minimum S3 multipart part size, so each chunk maps to exactly one part). + MINIMUM_S3_MULTIPART_PART_SIZE = 5 * 1024 * 1024 DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 - attr_reader :provider - attr_accessor :algorithm, :chunk_size, :logger + attr_reader :config def initialize - @algorithm = "aes-256-gcm" - @chunk_size = DEFAULT_CHUNK_SIZE - @provider = nil - @logger = Logger.new($stdout, level: Logger::WARN) + @config = ActiveSupport::OrderedOptions.new + self.algorithm = "aes-256-gcm" + self.chunk_size = DEFAULT_CHUNK_SIZE + self.encrypt_uploads = true + self.logger = Logger.new($stdout, level: Logger::WARN) + end + + def algorithm + config.algorithm + end + + def algorithm=(value) + config.algorithm = value + end + + def chunk_size + config.chunk_size + end + + def chunk_size=(value) + config.chunk_size = value + end + + def encrypt_uploads + config.encrypt_uploads + end + + def encrypt_uploads=(value) + config.encrypt_uploads = value + end + + def logger + config.logger + end + + def logger=(value) + config.logger = value + end + + def provider + config.provider end # Accept a provider instance or a symbol shorthand (:env, :aws_kms). def provider=(value) - @provider = case value - when Symbol then resolve_provider(value) - when Providers::Base then value - else - raise ArgumentError, - "provider must be a Providers::Base instance or " \ - "one of :env, :aws_kms — got #{value.inspect}" - end + config.provider = case value + when Symbol then resolve_provider(value) + when Providers::Base then value + else + raise ArgumentError, + "provider must be a Providers::Base instance or " \ + "one of :env, :aws_kms — got #{value.inspect}" + end end def validate! raise ProviderError, "No KMS provider configured. " \ - "Set ActiveCipherStorage.configuration.provider." unless @provider + "Set ActiveCipherStorage.configuration.provider." unless provider - unless ALGORITHMS.include?(@algorithm) - raise ArgumentError, "Unsupported algorithm: #{@algorithm.inspect}. " \ + unless ALGORITHMS.include?(algorithm) + raise ArgumentError, "Unsupported algorithm: #{algorithm.inspect}. " \ "Supported: #{ALGORITHMS.join(', ')}" end - raise ArgumentError, "chunk_size must be positive" unless @chunk_size.positive? + raise ArgumentError, "chunk_size must be positive" unless chunk_size.positive? + + return if [true, false].include?(encrypt_uploads) + + raise ArgumentError, "encrypt_uploads must be true or false" end private diff --git a/lib/active_cipher_storage/multipart_upload.rb b/lib/active_cipher_storage/multipart_upload.rb index 2d8e5fb..261237b 100644 --- a/lib/active_cipher_storage/multipart_upload.rb +++ b/lib/active_cipher_storage/multipart_upload.rb @@ -31,6 +31,7 @@ def initialize(s3_client:, bucket:, config: nil, store: nil) @config = config || ActiveCipherStorage.configuration @store = store || MemorySessionStore.new @config.validate! + validate_multipart_chunk_size! end # Starts a new multipart upload. Returns an opaque session_id. @@ -166,6 +167,14 @@ def save_session(id, data) @store.write(id, data, expires_in: SESSION_TTL) end + def validate_multipart_chunk_size! + min_size = Configuration::MINIMUM_S3_MULTIPART_PART_SIZE + return if @config.chunk_size >= min_size + + raise ArgumentError, + "chunk_size must be at least 5 MiB for S3 multipart uploads" + end + # Thread-safe in-memory session store backed by Concurrent::Map. # Replace with a Rails.cache wrapper for multi-process deployments. class MemorySessionStore diff --git a/lib/active_cipher_storage/stream_cipher.rb b/lib/active_cipher_storage/stream_cipher.rb index f60670f..3553d67 100644 --- a/lib/active_cipher_storage/stream_cipher.rb +++ b/lib/active_cipher_storage/stream_cipher.rb @@ -47,13 +47,19 @@ def decrypt(input_io, output_io) raise Errors::InvalidFormat, "Payload is not chunked; use Cipher#decrypt" unless header.chunked key = @provider.decrypt_data_key(header.encrypted_dek) + expected_seq = 1 loop do frame = Format.read_chunk(input_io) raise Errors::InvalidFormat, "Unexpected end of stream — missing final frame" if frame.nil? + validate_frame_sequence!(frame[:seq], expected_seq) output_io.write(decrypt_chunk(frame[:ciphertext], key, frame[:iv], frame[:auth_tag], frame[:seq])) break if frame[:seq] == Format::FINAL_SEQ + + expected_seq += 1 end + + ensure_no_trailing_bytes!(input_io) ensure zero_bytes!(key) end @@ -89,6 +95,20 @@ def decrypt_chunk(ciphertext, key, iv, auth_tag, seq) "Authentication failed on chunk seq=#{seq} — data may be tampered" end + def validate_frame_sequence!(seq, expected_seq) + return if seq == Format::FINAL_SEQ || seq == expected_seq + + raise Errors::InvalidFormat, + "Unexpected chunk sequence: expected #{expected_seq}, got #{seq}" + end + + def ensure_no_trailing_bytes!(input_io) + trailing = input_io.read(1) + return if trailing.nil? || trailing.empty? + + raise Errors::InvalidFormat, "Trailing bytes after final frame" + end + def build_cipher(mode, key, iv, auth_tag, seq) c = OpenSSL::Cipher.new(Cipher::OPENSSL_ALGO) mode == :encrypt ? c.encrypt : c.decrypt diff --git a/spec/integration/active_storage_spec.rb b/spec/integration/active_storage_spec.rb index 0d11a75..8c5a3c2 100644 --- a/spec/integration/active_storage_spec.rb +++ b/spec/integration/active_storage_spec.rb @@ -66,6 +66,22 @@ def delete_prefixed(pfx) svc.upload(key, StringIO.new(binary)) expect(svc.download(key)).to eq(binary) end + + it "stores plaintext for new uploads when encryption is disabled" do + ActiveCipherStorage.configure { |c| c.encrypt_uploads = false } + + svc.upload(key, StringIO.new(plaintext)) + + expect(inner.download(key)).to eq(plaintext) + expect(svc.download(key)).to eq(plaintext) + end + + it "still decrypts existing encrypted blobs when encryption is disabled" do + svc.upload(key, StringIO.new(plaintext)) + ActiveCipherStorage.configure { |c| c.encrypt_uploads = false } + + expect(svc.download(key)).to eq(plaintext) + end end describe "#download with block" do diff --git a/spec/integration/multipart_streaming_spec.rb b/spec/integration/multipart_streaming_spec.rb index e8ada30..39c0fd4 100644 --- a/spec/integration/multipart_streaming_spec.rb +++ b/spec/integration/multipart_streaming_spec.rb @@ -150,6 +150,28 @@ def upload_in_parts(plaintext, part_size:) adapter.stream_decrypted(key) { |_| } }.to raise_error(ActiveCipherStorage::Errors::DecryptionError) end + + it "rejects reordered authenticated frames" do + plaintext = SecureRandom.random_bytes(3 * chunk_size + 37) + upload_in_parts(plaintext, part_size: chunk_size) + + raw = s3.instance_variable_get(:@objects)[key] + header, frames = split_chunked_payload(raw) + s3.instance_variable_get(:@objects)[key] = header + frames[1] + frames[0] + frames[2..].join + + expect { + adapter.stream_decrypted(key) { |_| } + }.to raise_error(ActiveCipherStorage::Errors::InvalidFormat, /Unexpected chunk sequence/) + end + + it "rejects trailing bytes after the final frame" do + upload_in_parts("hello world".b, part_size: chunk_size) + s3.instance_variable_get(:@objects)[key] << "trailing" + + expect { + adapter.stream_decrypted(key) { |_| } + }.to raise_error(ActiveCipherStorage::Errors::InvalidFormat, /Trailing bytes/) + end end describe "invalid streaming inputs" do @@ -170,4 +192,20 @@ def upload_in_parts(plaintext, part_size:) }.to raise_error(ActiveCipherStorage::Errors::InvalidFormat, /Invalid magic bytes/) end end + + def split_chunked_payload(raw) + io = StringIO.new(raw) + ActiveCipherStorage::Format.read_header(io) + header = raw.byteslice(0, io.pos) + frames = [] + + until io.eof? + frame_start = io.pos + frame = ActiveCipherStorage::Format.read_chunk(io) + frames << raw.byteslice(frame_start, io.pos - frame_start) + break if frame[:seq] == ActiveCipherStorage::Format::FINAL_SEQ + end + + [header, frames] + end end diff --git a/spec/integration/s3_adapter_spec.rb b/spec/integration/s3_adapter_spec.rb index bd82c15..f3304f9 100644 --- a/spec/integration/s3_adapter_spec.rb +++ b/spec/integration/s3_adapter_spec.rb @@ -97,10 +97,10 @@ def abort_multipart_upload(bucket:, key:, upload_id:) describe "multipart upload (large file)" do before do - ActiveCipherStorage.configure { |c| c.chunk_size = 128 } + ActiveCipherStorage.configure { |c| c.chunk_size = 5 * 1024 * 1024 } end - let(:big_plaintext) { SecureRandom.random_bytes(600) } # > multipart_threshold + let(:big_plaintext) { SecureRandom.random_bytes((5 * 1024 * 1024) + 600) } # > multipart_threshold it "uses multiple parts for large files" do io = StringIO.new(big_plaintext) @@ -129,6 +129,16 @@ def abort_multipart_upload(bucket:, key:, upload_id:) expect { adapter.put_encrypted("large/file.bin", io) } .to raise_error(original_error) end + + it "rejects chunk sizes below S3 multipart minimum part size" do + ActiveCipherStorage.configure { |c| c.chunk_size = 1024 * 1024 } + + io = StringIO.new(big_plaintext) + allow(io).to receive(:size).and_return(big_plaintext.bytesize) + + expect { adapter.put_encrypted("large/file.bin", io) } + .to raise_error(ArgumentError, /at least 5 MiB/) + end end describe "#exist?" do diff --git a/spec/unit/blob_metadata_spec.rb b/spec/unit/blob_metadata_spec.rb index 88526f9..f098708 100644 --- a/spec/unit/blob_metadata_spec.rb +++ b/spec/unit/blob_metadata_spec.rb @@ -10,7 +10,9 @@ metadata: blob_metadata, update_columns: nil ).tap do |b| - allow(b).to receive(:update_columns) { |h| blob_metadata.merge!(h[:metadata] || h) } + allow(b).to receive(:update_columns) do |h| + blob_metadata.replace(h[:metadata] || h) + end end end @@ -62,6 +64,26 @@ def self.find_each(&block) = nil end end + describe ".write_plaintext" do + it "sets encrypted: false on the blob metadata" do + described_class.write_plaintext("key/abc") + expect(blob_metadata["encrypted"]).to be false + end + + it "removes stale encryption metadata" do + blob_metadata.merge!( + "encrypted" => true, + "cipher_version" => ActiveCipherStorage::Format::VERSION, + "provider_id" => "env", + "kms_key_id" => "ACTIVE_CIPHER_MASTER_KEY" + ) + + described_class.write_plaintext("key/abc") + + expect(blob_metadata).to eq("encrypted" => false) + end + end + describe ".update_after_rotation" do let(:provider) { ActiveCipherStorage.configuration.provider } diff --git a/spec/unit/configuration_spec.rb b/spec/unit/configuration_spec.rb new file mode 100644 index 0000000..f8cdd4d --- /dev/null +++ b/spec/unit/configuration_spec.rb @@ -0,0 +1,27 @@ +require "spec_helper" + +RSpec.describe ActiveCipherStorage::Configuration do + subject(:configuration) { described_class.new } + + describe "#config" do + it "backs regular settings with an ActiveSupport config object" do + configuration.config.algorithm = "aes-256-gcm" + + expect(configuration.algorithm).to eq("aes-256-gcm") + end + end + + describe "#encrypt_uploads" do + it "defaults to true" do + expect(configuration.encrypt_uploads).to be true + end + + it "rejects non-boolean values" do + configure_env_provider + ActiveCipherStorage.configure { |config| config.encrypt_uploads = "yes" } + + expect { ActiveCipherStorage.configuration.validate! } + .to raise_error(ArgumentError, /encrypt_uploads/) + end + end +end diff --git a/spec/unit/multipart_upload_spec.rb b/spec/unit/multipart_upload_spec.rb index 6cbd7e5..2a07dc7 100644 --- a/spec/unit/multipart_upload_spec.rb +++ b/spec/unit/multipart_upload_spec.rb @@ -26,6 +26,13 @@ let(:chunk_size) { ActiveCipherStorage.configuration.chunk_size } describe "#initiate" do + it "rejects chunk sizes below S3 multipart minimum part size" do + ActiveCipherStorage.configure { |c| c.chunk_size = 1024 * 1024 } + + expect { described_class.new(s3_client: s3, bucket: bucket) } + .to raise_error(ArgumentError, /at least 5 MiB/) + end + it "returns an opaque session_id string" do session_id = uploader.initiate(key: "uploads/doc.pdf") expect(session_id).to be_a(String) diff --git a/spec/unit/stream_cipher_spec.rb b/spec/unit/stream_cipher_spec.rb index 5fa8718..e8805ba 100644 --- a/spec/unit/stream_cipher_spec.rb +++ b/spec/unit/stream_cipher_spec.rb @@ -55,6 +55,22 @@ expect { cipher.decrypt_to_io(StringIO.new(raw)) } .to raise_error(ActiveCipherStorage::Errors::DecryptionError) end + + it "rejects reordered chunk frames" do + raw = cipher.encrypt_to_io(StringIO.new("x" * (chunk_size * 3 + 10))).read + header, frames = split_chunked_payload(raw) + reordered = header + frames[1] + frames[0] + frames[2..].join + + expect { cipher.decrypt_to_io(StringIO.new(reordered)) } + .to raise_error(ActiveCipherStorage::Errors::InvalidFormat, /Unexpected chunk sequence/) + end + + it "rejects trailing bytes after the final frame" do + raw = cipher.encrypt_to_io(StringIO.new("hello world")).read + + expect { cipher.decrypt_to_io(StringIO.new(raw + "trailing")) } + .to raise_error(ActiveCipherStorage::Errors::InvalidFormat, /Trailing bytes/) + end end describe "large file scenario" do @@ -75,4 +91,20 @@ expect(dec.read).to eq(plaintext) end end + + def split_chunked_payload(raw) + io = StringIO.new(raw) + ActiveCipherStorage::Format.read_header(io) + header = raw.byteslice(0, io.pos) + frames = [] + + until io.eof? + frame_start = io.pos + frame = ActiveCipherStorage::Format.read_chunk(io) + frames << raw.byteslice(frame_start, io.pos - frame_start) + break if frame[:seq] == ActiveCipherStorage::Format::FINAL_SEQ + end + + [header, frames] + end end From 61fd5b9e451644e7f9bcba5b7b7d48612cf9db02 Mon Sep 17 00:00:00 2001 From: Jaspreet Singh Date: Sat, 25 Apr 2026 17:38:51 +0400 Subject: [PATCH 2/2] chore: prepare 1.0.1 release Bumps the gem version and records the patch release notes for the post-1.0.0 fixes. --- CHANGELOG.md | 16 +++++++++++++++- Gemfile.lock | 2 +- lib/active_cipher_storage/version.rb | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd1938d..e5b1707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [1.0.1] - 2026-04-25 + +### Changed + +- Back gem configuration with Rails-style ActiveSupport options while preserving the existing public configuration API. +- Document the Active Storage upload encryption flag and plaintext read compatibility behavior. + +### Fixed + +- Reject reordered streaming frames and trailing bytes after the final encrypted frame. +- Validate S3 multipart chunk sizes before upload so invalid part sizes fail early. +- Mark plaintext Active Storage uploads explicitly when encryption is disabled. + ## [1.0.0] - 2026-04-25 ### Added @@ -20,5 +33,6 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht - Header-only key rotation for re-wrapping encrypted DEKs. - Unit and integration coverage for crypto, providers, Active Storage, S3, multipart upload, streaming, metadata, and key rotation. -[Unreleased]: https://github.com/codebyjass/active-cipher-storage/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/codebyjass/active-cipher-storage/compare/v1.0.1...HEAD +[1.0.1]: https://github.com/codebyjass/active-cipher-storage/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/codebyjass/active-cipher-storage/releases/tag/v1.0.0 diff --git a/Gemfile.lock b/Gemfile.lock index bbe46b8..4947be5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - active_cipher_storage (1.0.0) + active_cipher_storage (1.0.1) activesupport (>= 7.0, < 9.0) concurrent-ruby (~> 1.2) diff --git a/lib/active_cipher_storage/version.rb b/lib/active_cipher_storage/version.rb index 35c7887..8205579 100644 --- a/lib/active_cipher_storage/version.rb +++ b/lib/active_cipher_storage/version.rb @@ -1,3 +1,3 @@ module ActiveCipherStorage - VERSION = "1.0.0" + VERSION = "1.0.1" end