Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
3 changes: 2 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
PATH
remote: .
specs:
active_cipher_storage (1.0.0)
active_cipher_storage (1.0.1)
activesupport (>= 7.0, < 9.0)
concurrent-ruby (~> 1.2)

GEM
Expand Down
45 changes: 7 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -91,8 +85,6 @@ gem "aws-sdk-s3"
bundle install
```

---

## Rails / Active Storage setup

### 1. Configure a KMS provider
Expand All @@ -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
```

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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 |
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions active_cipher_storage.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions lib/active_cipher_storage/adapters/active_storage_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 26 additions & 2 deletions lib/active_cipher_storage/adapters/s3_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -180,17 +188,24 @@ 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
end

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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions lib/active_cipher_storage/blob_metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
Loading
Loading