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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/CONTRIBUTING.md +46 -0
- data/LICENSE +21 -0
- data/README.md +644 -0
- data/SECURITY.md +35 -0
- data/active_cipher_storage.gemspec +55 -0
- data/lib/active_cipher_storage/adapters/active_storage_service.rb +129 -0
- data/lib/active_cipher_storage/adapters/s3_adapter.rb +252 -0
- data/lib/active_cipher_storage/blob_metadata.rb +84 -0
- data/lib/active_cipher_storage/cipher.rb +97 -0
- data/lib/active_cipher_storage/configuration.rb +57 -0
- data/lib/active_cipher_storage/engine.rb +29 -0
- data/lib/active_cipher_storage/errors.rb +31 -0
- data/lib/active_cipher_storage/format.rb +126 -0
- data/lib/active_cipher_storage/key_rotation.rb +121 -0
- data/lib/active_cipher_storage/key_utils.rb +12 -0
- data/lib/active_cipher_storage/multipart_upload.rb +190 -0
- data/lib/active_cipher_storage/providers/aws_kms_provider.rb +90 -0
- data/lib/active_cipher_storage/providers/base.rb +38 -0
- data/lib/active_cipher_storage/providers/env_provider.rb +122 -0
- data/lib/active_cipher_storage/stream_cipher.rb +102 -0
- data/lib/active_cipher_storage/version.rb +3 -0
- data/lib/active_cipher_storage.rb +43 -0
- data/lib/active_storage/service/active_cipher_storage_service.rb +10 -0
- metadata +224 -0
|
@@ -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
|