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.
@@ -0,0 +1,29 @@
1
+ require "rails"
2
+
3
+ module ActiveCipherStorage
4
+ class Engine < Rails::Engine
5
+ isolate_namespace ActiveCipherStorage
6
+
7
+ initializer "active_cipher_storage.setup" do
8
+ ActiveSupport.on_load(:active_storage) do
9
+ # Register our service so Rails' configurator can resolve it by name.
10
+ # When config/storage.yml has `service: ActiveCipherStorage`, Rails
11
+ # looks for ActiveStorage::Service::ActiveCipherStorageService or falls
12
+ # back to a registered mapping. We register both names for safety.
13
+ require "active_cipher_storage/adapters/active_storage_service"
14
+
15
+ # Rails 7.1+ uses Configurator#build which calls
16
+ # SomeServiceClass.build(configurator:, **opts) when defined.
17
+ # Older Rails falls back to .new(**opts). Both are supported.
18
+ ActiveStorage::Service.send(:const_set,
19
+ :ActiveCipherStorageService,
20
+ ActiveCipherStorage::Adapters::ActiveStorageService
21
+ ) unless ActiveStorage::Service.const_defined?(:ActiveCipherStorageService, false)
22
+ end
23
+ end
24
+
25
+ initializer "active_cipher_storage.log_subscriber" do
26
+ ActiveSupport::LogSubscriber.logger ||= Logger.new($stdout)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,31 @@
1
+ module ActiveCipherStorage
2
+ module Errors
3
+ # Base class for all gem errors.
4
+ class Error < StandardError; end
5
+
6
+ # Raised when the binary header is malformed or the magic bytes are wrong.
7
+ class InvalidFormat < Error; end
8
+
9
+ # Raised when GCM authentication tag verification fails (data tampered or
10
+ # wrong key). Deliberately vague to avoid oracle attacks.
11
+ class DecryptionError < Error; end
12
+
13
+ # Raised when a required KMS provider is not configured.
14
+ class ProviderError < Error; end
15
+
16
+ # Raised when the KMS provider cannot wrap/unwrap a data key.
17
+ class KeyManagementError < ProviderError; end
18
+
19
+ # Raised when a caller tries to use a feature the active provider doesn't
20
+ # implement (e.g. key rotation on EnvProvider).
21
+ class UnsupportedOperation < Error; end
22
+ end
23
+
24
+ # Convenience aliases at the top-level namespace.
25
+ Error = Errors::Error
26
+ InvalidFormat = Errors::InvalidFormat
27
+ DecryptionError = Errors::DecryptionError
28
+ ProviderError = Errors::ProviderError
29
+ KeyManagementError = Errors::KeyManagementError
30
+ UnsupportedOperation = Errors::UnsupportedOperation
31
+ end
@@ -0,0 +1,126 @@
1
+ module ActiveCipherStorage
2
+ # Binary format v1:
3
+ #
4
+ # Header
5
+ # [4] Magic "ACS\x01"
6
+ # [1] Version (0x01)
7
+ # [1] Algorithm (0x01 = AES-256-GCM)
8
+ # [1] Flags (bit 0 = chunked)
9
+ # [4] Chunk-size hint (uint32 BE; 0 if non-chunked)
10
+ # [2] Provider-ID length (uint16 BE)
11
+ # [N] Provider ID (UTF-8)
12
+ # [2] Encrypted DEK length (uint16 BE)
13
+ # [M] Encrypted DEK bytes
14
+ #
15
+ # Non-chunked payload: [12 IV] [K ciphertext] [16 auth-tag]
16
+ #
17
+ # Chunked payload (repeat until seq == FINAL_SEQ):
18
+ # [4] Sequence number (1-based; FINAL_SEQ = 0xFFFFFFFF marks last frame)
19
+ # [12] Chunk IV
20
+ # [4] Ciphertext length (uint32 BE)
21
+ # [K] Ciphertext
22
+ # [16] Auth tag
23
+ #
24
+ # The final frame may carry zero-length ciphertext when the plaintext length
25
+ # is an exact multiple of chunk_size.
26
+ module Format
27
+ MAGIC = "ACS\x01".b.freeze
28
+ VERSION = 0x01
29
+ ALGO_AES256GCM = 0x01
30
+ FLAG_CHUNKED = 0x01
31
+ IV_SIZE = 12
32
+ AUTH_TAG_SIZE = 16
33
+ FINAL_SEQ = 0xFFFF_FFFF
34
+
35
+ Header = Struct.new(
36
+ :version, :algorithm, :chunked, :chunk_size, :provider_id, :encrypted_dek,
37
+ keyword_init: true
38
+ )
39
+
40
+ def self.write_header(io, header)
41
+ provider_bytes = header.provider_id.encode("UTF-8").b
42
+ flags = header.chunked ? FLAG_CHUNKED : 0x00
43
+
44
+ io.write(MAGIC)
45
+ io.write([VERSION].pack("C"))
46
+ io.write([ALGO_AES256GCM].pack("C"))
47
+ io.write([flags].pack("C"))
48
+ io.write([header.chunk_size.to_i].pack("N"))
49
+ io.write([provider_bytes.bytesize].pack("n"))
50
+ io.write(provider_bytes)
51
+ io.write([header.encrypted_dek.bytesize].pack("n"))
52
+ io.write(header.encrypted_dek)
53
+ end
54
+
55
+ def self.read_header(io)
56
+ magic = safe_read(io, 4)
57
+ raise Errors::InvalidFormat, "Invalid magic bytes" unless magic == MAGIC
58
+
59
+ version = safe_read(io, 1).unpack1("C")
60
+ algorithm = safe_read(io, 1).unpack1("C")
61
+ flags = safe_read(io, 1).unpack1("C")
62
+ chunk_sz = safe_read(io, 4).unpack1("N")
63
+
64
+ validate_header_fields!(version, algorithm, flags)
65
+
66
+ provider_len = safe_read(io, 2).unpack1("n")
67
+ provider_id = safe_read(io, provider_len).force_encoding("UTF-8")
68
+
69
+ dek_len = safe_read(io, 2).unpack1("n")
70
+ encrypted_dek = safe_read(io, dek_len)
71
+
72
+ Header.new(
73
+ version: version,
74
+ algorithm: algorithm,
75
+ chunked: (flags & FLAG_CHUNKED) != 0,
76
+ chunk_size: chunk_sz,
77
+ provider_id: provider_id,
78
+ encrypted_dek: encrypted_dek
79
+ )
80
+ end
81
+
82
+ def self.write_chunk(io, seq:, iv:, ciphertext:, auth_tag:)
83
+ io.write([seq].pack("N"))
84
+ io.write(iv)
85
+ io.write([ciphertext.bytesize].pack("N"))
86
+ io.write(ciphertext)
87
+ io.write(auth_tag)
88
+ end
89
+
90
+ # Returns { seq:, iv:, ciphertext:, auth_tag: } or nil on clean EOF.
91
+ def self.read_chunk(io)
92
+ seq_bytes = io.read(4)
93
+ return nil if seq_bytes.nil? || seq_bytes.empty?
94
+
95
+ seq = seq_bytes.unpack1("N")
96
+ iv = safe_read(io, IV_SIZE)
97
+ ct_len = safe_read(io, 4).unpack1("N")
98
+ ciphertext = ct_len.positive? ? safe_read(io, ct_len) : "".b
99
+ auth_tag = safe_read(io, AUTH_TAG_SIZE)
100
+
101
+ { seq: seq, iv: iv, ciphertext: ciphertext, auth_tag: auth_tag }
102
+ end
103
+
104
+ private_class_method def self.safe_read(io, n)
105
+ data = io.read(n)
106
+ unless data && data.bytesize == n
107
+ raise Errors::InvalidFormat,
108
+ "Unexpected end of stream: expected #{n} bytes, got #{data&.bytesize || 0}"
109
+ end
110
+ data
111
+ end
112
+
113
+ private_class_method def self.validate_header_fields!(version, algorithm, flags)
114
+ raise Errors::InvalidFormat, "Unsupported version: #{version}" unless version == VERSION
115
+
116
+ unless algorithm == ALGO_AES256GCM
117
+ raise Errors::InvalidFormat, "Unsupported algorithm: #{algorithm}"
118
+ end
119
+
120
+ unknown_flags = flags & ~FLAG_CHUNKED
121
+ return if unknown_flags.zero?
122
+
123
+ raise Errors::InvalidFormat, "Unsupported flags: 0x#{unknown_flags.to_s(16)}"
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,121 @@
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
@@ -0,0 +1,12 @@
1
+ module ActiveCipherStorage
2
+ module KeyUtils
3
+ private
4
+
5
+ # Best-effort in-place zeroing. Ruby GC may retain copies, but this
6
+ # reduces the window during which a key sits in heap memory.
7
+ def zero_bytes!(str)
8
+ return unless str.is_a?(String)
9
+ str.bytesize.times { |i| str.setbyte(i, 0) }
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,190 @@
1
+ require "openssl"
2
+ require "securerandom"
3
+ require "concurrent"
4
+
5
+ module ActiveCipherStorage
6
+ # Session-based multipart upload where the caller sends plaintext chunks
7
+ # across separate HTTP requests. Each chunk is encrypted as an ACS frame
8
+ # before being accumulated and flushed to S3.
9
+ #
10
+ # S3 requires every part except the last to be >= 5 MiB. Chunks can be any
11
+ # size — this class buffers encrypted frames and flushes S3 parts only when
12
+ # the buffer reaches chunk_size (default 5 MiB).
13
+ #
14
+ # Flow:
15
+ # uploader = EncryptedMultipartUpload.new(s3_client:, bucket:)
16
+ # session_id = uploader.initiate(key: "uploads/doc.pdf")
17
+ # uploader.upload_part(session_id:, chunk_io: io1) # repeat per chunk
18
+ # uploader.complete(session_id:)
19
+ #
20
+ # Session state is kept in an in-memory store by default.
21
+ # For multi-process deployments pass store: Rails.cache (or any object
22
+ # that responds to read/write/delete with the same keyword signatures).
23
+ class EncryptedMultipartUpload
24
+ include KeyUtils
25
+
26
+ SESSION_TTL = 24 * 3600
27
+
28
+ def initialize(s3_client:, bucket:, config: nil, store: nil)
29
+ @s3 = s3_client
30
+ @bucket = bucket
31
+ @config = config || ActiveCipherStorage.configuration
32
+ @store = store || MemorySessionStore.new
33
+ @config.validate!
34
+ end
35
+
36
+ # Starts a new multipart upload. Returns an opaque session_id.
37
+ def initiate(key:, metadata: {})
38
+ dek_bundle = @config.provider.generate_data_key
39
+ s3_opts = { content_type: "application/octet-stream" }
40
+ s3_opts[:metadata] = metadata unless metadata.empty?
41
+ upload_id = @s3.create_multipart_upload(bucket: @bucket, key: key, **s3_opts).upload_id
42
+
43
+ header_io = StringIO.new("".b)
44
+ Format.write_header(header_io, Format::Header.new(
45
+ version: Format::VERSION,
46
+ algorithm: Format::ALGO_AES256GCM,
47
+ chunked: true,
48
+ chunk_size: @config.chunk_size,
49
+ provider_id: @config.provider.provider_id,
50
+ encrypted_dek: dek_bundle[:encrypted_key]
51
+ ))
52
+
53
+ session_id = SecureRandom.urlsafe_base64(24)
54
+ save_session(session_id, {
55
+ upload_id: upload_id,
56
+ key: key,
57
+ encrypted_dek: dek_bundle[:encrypted_key],
58
+ seq: 0,
59
+ parts: [],
60
+ pending: header_io.string
61
+ })
62
+ session_id
63
+ ensure
64
+ zero_bytes!(dek_bundle&.dig(:plaintext_key))
65
+ end
66
+
67
+ # Encrypts a chunk and buffers it. Flushes complete S3 parts (>= chunk_size)
68
+ # automatically. Returns { status: :ok, parts_uploaded: N }.
69
+ def upload_part(session_id:, chunk_io:)
70
+ session = load_session!(session_id)
71
+ plaintext = chunk_io.read.b
72
+ session[:seq] += 1
73
+
74
+ session[:pending] = (session[:pending] +
75
+ build_frame(plaintext, session[:encrypted_dek], session[:seq])).b
76
+
77
+ while session[:pending].bytesize >= @config.chunk_size
78
+ flush_part(session, session[:pending].byteslice(0, @config.chunk_size))
79
+ session[:pending] = (session[:pending].byteslice(@config.chunk_size..) || "".b).b
80
+ end
81
+
82
+ save_session(session_id, session)
83
+ { status: :ok, parts_uploaded: session[:parts].length }
84
+ ensure
85
+ zero_bytes!(plaintext)
86
+ end
87
+
88
+ # Writes a zero-byte FINAL_SEQ sentinel frame, flushes remaining bytes as
89
+ # the last S3 part, and completes the multipart upload.
90
+ # Returns { status: :completed, key:, parts_count: }.
91
+ def complete(session_id:)
92
+ session = load_session!(session_id)
93
+
94
+ # Zero-byte final frame signals end-of-stream to the decryptor.
95
+ session[:pending] = (session[:pending] +
96
+ build_frame("".b, session[:encrypted_dek], Format::FINAL_SEQ)).b
97
+
98
+ flush_part(session, session[:pending]) unless session[:pending].empty?
99
+ session[:pending] = "".b
100
+
101
+ @s3.complete_multipart_upload(
102
+ bucket: @bucket, key: session[:key],
103
+ upload_id: session[:upload_id],
104
+ multipart_upload: { parts: session[:parts] }
105
+ )
106
+ @store.delete(session_id)
107
+ { status: :completed, key: session[:key], parts_count: session[:parts].length }
108
+ rescue StandardError
109
+ abort_s3(session)
110
+ @store.delete(session_id)
111
+ raise
112
+ end
113
+
114
+ # Aborts the in-progress S3 multipart upload and discards the session.
115
+ def abort(session_id:)
116
+ session = @store.read(session_id)
117
+ return unless session
118
+ abort_s3(session)
119
+ @store.delete(session_id)
120
+ end
121
+
122
+ private
123
+
124
+ def build_frame(plaintext, encrypted_dek, seq)
125
+ dek = @config.provider.decrypt_data_key(encrypted_dek)
126
+ iv = SecureRandom.random_bytes(Format::IV_SIZE)
127
+ c = chunk_cipher(dek, iv, seq)
128
+ ct = plaintext.empty? ? c.final : (c.update(plaintext) + c.final)
129
+ buf = StringIO.new("".b)
130
+ Format.write_chunk(buf, seq: seq, iv: iv, ciphertext: ct, auth_tag: c.auth_tag)
131
+ buf.string
132
+ ensure
133
+ zero_bytes!(dek)
134
+ end
135
+
136
+ def flush_part(session, bytes)
137
+ pn = session[:parts].length + 1
138
+ etag = @s3.upload_part(bucket: @bucket, key: session[:key],
139
+ upload_id: session[:upload_id],
140
+ part_number: pn, body: bytes).etag
141
+ session[:parts] << { part_number: pn, etag: etag }
142
+ end
143
+
144
+ def abort_s3(session)
145
+ @s3.abort_multipart_upload(bucket: @bucket, key: session[:key],
146
+ upload_id: session[:upload_id])
147
+ rescue StandardError
148
+ nil # best-effort abort
149
+ end
150
+
151
+ def chunk_cipher(key, iv, seq)
152
+ c = OpenSSL::Cipher.new(Cipher::OPENSSL_ALGO)
153
+ c.encrypt
154
+ c.key = key
155
+ c.iv = iv
156
+ c.auth_data = [seq].pack("N")
157
+ c
158
+ end
159
+
160
+ def load_session!(id)
161
+ @store.read(id) or
162
+ raise Errors::Error, "Upload session not found or expired: #{id}"
163
+ end
164
+
165
+ def save_session(id, data)
166
+ @store.write(id, data, expires_in: SESSION_TTL)
167
+ end
168
+
169
+ # Thread-safe in-memory session store backed by Concurrent::Map.
170
+ # Replace with a Rails.cache wrapper for multi-process deployments.
171
+ class MemorySessionStore
172
+ def initialize
173
+ @data = Concurrent::Map.new
174
+ end
175
+
176
+ def read(id)
177
+ entry = @data[id]
178
+ return nil unless entry
179
+ return nil if entry[:expires_at] && Time.now.to_i > entry[:expires_at]
180
+ entry[:data]
181
+ end
182
+
183
+ def write(id, data, expires_in: nil)
184
+ @data[id] = { data: data, expires_at: expires_in && Time.now.to_i + expires_in }
185
+ end
186
+
187
+ def delete(id) = @data.delete(id)
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,90 @@
1
+ module ActiveCipherStorage
2
+ module Providers
3
+ class AwsKmsProvider < Base
4
+ include KeyUtils
5
+
6
+ PROVIDER_ID = "aws_kms"
7
+
8
+ def initialize(key_id: nil, region: nil, encryption_context: {}, client: nil)
9
+ @key_id = key_id || ENV.fetch("AWS_KMS_KEY_ID") {
10
+ raise Errors::ProviderError,
11
+ "AwsKmsProvider requires :key_id or AWS_KMS_KEY_ID env var"
12
+ }
13
+ @region = region
14
+ @encryption_context = encryption_context || {}
15
+ @client_override = client
16
+ end
17
+
18
+ def provider_id = PROVIDER_ID
19
+ def key_id = @key_id
20
+
21
+ def generate_data_key
22
+ resp = kms_client.generate_data_key(
23
+ key_id: @key_id,
24
+ key_spec: "AES_256",
25
+ encryption_context: @encryption_context
26
+ )
27
+ { plaintext_key: resp.plaintext.dup, encrypted_key: resp.ciphertext_blob.dup }
28
+ rescue Aws::KMS::Errors::ServiceError => e
29
+ raise Errors::KeyManagementError, "KMS GenerateDataKey failed: #{e.message}"
30
+ ensure
31
+ # AWS SDK may retain a reference to resp.plaintext; zero our copy too.
32
+ resp&.plaintext&.then { |k| zero_bytes!(k) }
33
+ end
34
+
35
+ def decrypt_data_key(encrypted_key)
36
+ resp = kms_client.decrypt(
37
+ ciphertext_blob: encrypted_key,
38
+ encryption_context: @encryption_context
39
+ )
40
+ resp.plaintext.dup
41
+ rescue Aws::KMS::Errors::InvalidCiphertextException,
42
+ Aws::KMS::Errors::IncorrectKeyException => e
43
+ raise Errors::KeyManagementError,
44
+ "KMS Decrypt failed — wrong key or tampered DEK: #{e.message}"
45
+ rescue Aws::KMS::Errors::ServiceError => e
46
+ raise Errors::KeyManagementError, "KMS Decrypt failed: #{e.message}"
47
+ ensure
48
+ resp&.plaintext&.then { |k| zero_bytes!(k) }
49
+ end
50
+
51
+ # Encrypts an existing plaintext DEK using KMS Encrypt.
52
+ # Prefer rotate_data_key (ReEncrypt) when both old and new providers are AWS KMS,
53
+ # because ReEncrypt keeps the plaintext DEK entirely within KMS.
54
+ def wrap_data_key(plaintext_dek)
55
+ resp = kms_client.encrypt(
56
+ key_id: @key_id,
57
+ plaintext: plaintext_dek,
58
+ encryption_context: @encryption_context
59
+ )
60
+ resp.ciphertext_blob.dup
61
+ rescue Aws::KMS::Errors::ServiceError => e
62
+ raise Errors::KeyManagementError, "KMS Encrypt failed: #{e.message}"
63
+ end
64
+
65
+ # Uses KMS ReEncrypt — the plaintext DEK never leaves KMS.
66
+ def rotate_data_key(encrypted_key, destination_key_id: nil)
67
+ resp = kms_client.re_encrypt(
68
+ ciphertext_blob: encrypted_key,
69
+ source_encryption_context: @encryption_context,
70
+ destination_key_id: destination_key_id || @key_id,
71
+ destination_encryption_context: @encryption_context
72
+ )
73
+ resp.ciphertext_blob.dup
74
+ rescue Aws::KMS::Errors::ServiceError => e
75
+ raise Errors::KeyManagementError, "KMS ReEncrypt failed: #{e.message}"
76
+ end
77
+
78
+ private
79
+
80
+ def kms_client
81
+ @kms_client ||= begin
82
+ require "aws-sdk-kms"
83
+ @client_override || Aws::KMS::Client.new(**{ region: @region }.compact)
84
+ end
85
+ rescue LoadError
86
+ raise Errors::ProviderError, "aws-sdk-kms is required: add it to your Gemfile"
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,38 @@
1
+ module ActiveCipherStorage
2
+ module Providers
3
+ class Base
4
+ # Returns { plaintext_key: String (32 bytes), encrypted_key: String }
5
+ def generate_data_key
6
+ raise NotImplementedError, "#{self.class}#generate_data_key is not implemented"
7
+ end
8
+
9
+ # Returns the plaintext DEK (32 bytes). Caller must zero it after use.
10
+ def decrypt_data_key(encrypted_key)
11
+ raise NotImplementedError, "#{self.class}#decrypt_data_key is not implemented"
12
+ end
13
+
14
+ # Wraps an existing plaintext DEK under this provider's master key.
15
+ # Used during key rotation to re-protect a DEK without re-encrypting the file.
16
+ def wrap_data_key(plaintext_dek)
17
+ raise NotImplementedError, "#{self.class}#wrap_data_key is not implemented"
18
+ end
19
+
20
+ # Short ASCII string embedded in every encrypted file header.
21
+ def provider_id
22
+ raise NotImplementedError, "#{self.class}#provider_id is not implemented"
23
+ end
24
+
25
+ # Stable identifier for the specific key material in use (e.g. CMK ARN,
26
+ # env var name). Stored in blob metadata for rotation queries.
27
+ # Returns nil for providers where key identity is not meaningful.
28
+ def key_id
29
+ nil
30
+ end
31
+
32
+ def rotate_data_key(encrypted_key)
33
+ raise Errors::UnsupportedOperation,
34
+ "#{self.class} does not support key rotation"
35
+ end
36
+ end
37
+ end
38
+ end