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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -6
- data/CONTRIBUTING.md +0 -1
- data/README.md +101 -123
- data/lib/active_cipher_storage/adapters/s3_adapter.rb +11 -7
- data/lib/active_cipher_storage/blob_metadata.rb +16 -47
- data/lib/active_cipher_storage/configuration.rb +59 -26
- data/lib/active_cipher_storage/engine.rb +1 -17
- data/lib/active_cipher_storage/errors.rb +2 -2
- data/lib/active_cipher_storage/multipart_upload.rb +12 -10
- data/lib/active_cipher_storage/providers/aws_kms_provider.rb +22 -38
- data/lib/active_cipher_storage/providers/base.rb +2 -13
- data/lib/active_cipher_storage/providers/env_provider.rb +11 -34
- data/lib/active_cipher_storage/stream_cipher.rb +5 -2
- data/lib/active_cipher_storage/version.rb +1 -1
- data/lib/active_cipher_storage.rb +0 -2
- data/lib/active_storage/service/active_cipher_storage_service.rb +181 -4
- metadata +2 -4
- data/lib/active_cipher_storage/adapters/active_storage_service.rb +0 -140
- data/lib/active_cipher_storage/key_rotation.rb +0 -121
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:
|
|
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-
|
|
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
|