active_cipher_storage 1.0.2 → 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 +34 -6
- data/CONTRIBUTING.md +0 -1
- data/README.md +185 -187
- 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
|
@@ -1,10 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage"
|
|
1
4
|
require "active_storage/service"
|
|
2
|
-
require "active_cipher_storage
|
|
5
|
+
require "active_cipher_storage"
|
|
6
|
+
require "active_cipher_storage/cipher"
|
|
7
|
+
require "active_cipher_storage/stream_cipher"
|
|
8
|
+
require "active_cipher_storage/errors"
|
|
9
|
+
require "active_cipher_storage/format"
|
|
10
|
+
require "active_cipher_storage/blob_metadata"
|
|
3
11
|
|
|
4
12
|
module ActiveStorage
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
13
|
+
# Encrypting wrapper around another Active Storage service (e.g. S3). Configure
|
|
14
|
+
# in +config/storage.yml+ with +service: ActiveCipherStorage+ and
|
|
15
|
+
# +wrapped_service:+ pointing at a named service.
|
|
16
|
+
#
|
|
17
|
+
# See the gem README for behavior and caveats.
|
|
18
|
+
class Service::ActiveCipherStorageService < Service
|
|
19
|
+
attr_reader :inner
|
|
20
|
+
|
|
21
|
+
def self.build(configurator:, name:, wrapped_service:, service: nil, **service_config)
|
|
22
|
+
new(wrapped_service: configurator.build(wrapped_service), **service_config).tap do |instance|
|
|
23
|
+
instance.name = name.to_s
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(wrapped_service:, public: nil,
|
|
28
|
+
chunk_size: ActiveCipherStorage::Configuration::DEFAULT_CHUNK_SIZE, **)
|
|
29
|
+
@inner = wrapped_service
|
|
30
|
+
@chunk_size = Integer(chunk_size)
|
|
31
|
+
@cipher = ActiveCipherStorage::Cipher.new
|
|
32
|
+
@stream_cipher = ActiveCipherStorage::StreamCipher.new(chunk_size: @chunk_size)
|
|
33
|
+
@public =
|
|
34
|
+
if public.nil?
|
|
35
|
+
wrapped_service.respond_to?(:public?) ? wrapped_service.public? : false
|
|
36
|
+
else
|
|
37
|
+
public
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def upload(key, io, checksum: nil, **options)
|
|
42
|
+
instrument :upload, key: key, checksum: checksum do
|
|
43
|
+
upload_without_instrument(key, io, checksum: checksum, **options)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def download(key, &block)
|
|
48
|
+
if block_given?
|
|
49
|
+
instrument :streaming_download, key: key do
|
|
50
|
+
plain = download_without_block(key)
|
|
51
|
+
yield plain
|
|
52
|
+
end
|
|
53
|
+
else
|
|
54
|
+
instrument :download, key: key do
|
|
55
|
+
download_without_block(key)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def download_chunk(key, range)
|
|
61
|
+
instrument :download_chunk, key: key, range: range do
|
|
62
|
+
download_without_block(key).b[range]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns stored ciphertext bytes (for custom tooling, backups, or migrations).
|
|
67
|
+
def download_raw(key)
|
|
68
|
+
collect_download(key)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Writes raw ciphertext bytes without encrypting (for advanced use only).
|
|
72
|
+
def upload_raw(key, io)
|
|
73
|
+
instrument :upload, key: key, checksum: nil do
|
|
74
|
+
inner.upload(key, io, content_type: "application/octet-stream")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def delete(key)
|
|
79
|
+
instrument :delete, key: key do
|
|
80
|
+
inner.delete(key)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def delete_prefixed(prefix)
|
|
85
|
+
instrument :delete_prefixed, prefix: prefix do
|
|
86
|
+
inner.delete_prefixed(prefix)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def exist?(key)
|
|
91
|
+
instrument :exist, key: key do |payload|
|
|
92
|
+
answer = inner.exist?(key)
|
|
93
|
+
payload[:exist] = answer
|
|
94
|
+
answer
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def update_metadata(key, **metadata)
|
|
99
|
+
inner.update_metadata(key, **metadata)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def compose(source_keys, destination_key, **options)
|
|
103
|
+
inner.compose(source_keys, destination_key, **options)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def path_for(key)
|
|
107
|
+
unless inner.respond_to?(:path_for)
|
|
108
|
+
raise NotImplementedError,
|
|
109
|
+
"#{inner.class.name} does not implement path_for — use a disk-backed " \
|
|
110
|
+
"inner service if you need local paths"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
inner.path_for(key)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def url_for_direct_upload(*)
|
|
117
|
+
raise ActiveCipherStorage::Errors::UnsupportedOperation,
|
|
118
|
+
"Direct uploads bypass encryption — use server-side upload instead"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def headers_for_direct_upload(*) = {}
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def private_url(key, **options)
|
|
126
|
+
inner.send(:private_url, key, **options)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def public_url(key, **options)
|
|
130
|
+
inner.send(:public_url, key, **options)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def upload_without_instrument(key, io, checksum: nil, **options)
|
|
134
|
+
unless ActiveCipherStorage.configuration.encrypt_uploads
|
|
135
|
+
inner.upload(key, io, checksum: checksum, **options)
|
|
136
|
+
ActiveCipherStorage::BlobMetadata.write_plaintext(key)
|
|
137
|
+
return
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
inner.upload(key, encrypt_io(io), **options,
|
|
141
|
+
checksum: nil,
|
|
142
|
+
content_type: "application/octet-stream")
|
|
143
|
+
|
|
144
|
+
ActiveCipherStorage::BlobMetadata.write(key, ActiveCipherStorage.configuration.provider)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def download_without_block(key)
|
|
148
|
+
raw = collect_download(key)
|
|
149
|
+
|
|
150
|
+
return raw unless cipher_payload?(raw)
|
|
151
|
+
|
|
152
|
+
decrypt_raw(raw)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def encrypt_io(io)
|
|
156
|
+
if io.respond_to?(:size) && io.size && io.size > @chunk_size
|
|
157
|
+
@stream_cipher.encrypt_to_io(io)
|
|
158
|
+
else
|
|
159
|
+
StringIO.new(@cipher.encrypt(io))
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def collect_download(key)
|
|
164
|
+
buffer = StringIO.new("".b)
|
|
165
|
+
result = inner.download(key) { |chunk| buffer.write(chunk) }
|
|
166
|
+
buffer.write(result.b) if buffer.pos.zero? && result.is_a?(String)
|
|
167
|
+
buffer.string
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def decrypt_raw(raw)
|
|
171
|
+
io = StringIO.new(raw.b)
|
|
172
|
+
header = ActiveCipherStorage::Format.read_header(io)
|
|
173
|
+
io.rewind
|
|
174
|
+
header.chunked ? @stream_cipher.decrypt_to_io(io).read : @cipher.decrypt(io)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def cipher_payload?(data)
|
|
178
|
+
data.b.start_with?(ActiveCipherStorage::Format::MAGIC)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Base implementation uses +Array#third+ (ActiveSupport); loading order may not
|
|
182
|
+
# include array extensions, so we keep a stable notification label.
|
|
183
|
+
def service_name
|
|
184
|
+
"ActiveCipherStorage"
|
|
8
185
|
end
|
|
9
186
|
end
|
|
10
187
|
end
|
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
|