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
|
@@ -8,13 +8,16 @@ module ActiveCipherStorage
|
|
|
8
8
|
DEFAULT_MULTIPART_THRESHOLD = 100 * 1024 * 1024
|
|
9
9
|
|
|
10
10
|
def initialize(bucket:, region: nil, multipart_threshold: DEFAULT_MULTIPART_THRESHOLD,
|
|
11
|
-
s3_client: nil, config: nil
|
|
11
|
+
s3_client: nil, config: nil,
|
|
12
|
+
chunk_size: Configuration::DEFAULT_CHUNK_SIZE)
|
|
12
13
|
@bucket = bucket
|
|
13
14
|
@region = region
|
|
14
15
|
@multipart_threshold = multipart_threshold
|
|
15
16
|
@client_override = s3_client
|
|
16
17
|
@config = config || ActiveCipherStorage.configuration
|
|
18
|
+
@chunk_size = Integer(chunk_size)
|
|
17
19
|
@config.validate!
|
|
20
|
+
raise ArgumentError, "chunk_size must be positive" unless @chunk_size.positive?
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
def put_encrypted(key, io, **options)
|
|
@@ -89,7 +92,7 @@ module ActiveCipherStorage
|
|
|
89
92
|
version: Format::VERSION,
|
|
90
93
|
algorithm: Format::ALGO_AES256GCM,
|
|
91
94
|
chunked: true,
|
|
92
|
-
chunk_size: @
|
|
95
|
+
chunk_size: @chunk_size,
|
|
93
96
|
provider_id: @config.provider.provider_id,
|
|
94
97
|
encrypted_dek: dek_bundle.fetch(:encrypted_key)
|
|
95
98
|
))
|
|
@@ -97,8 +100,8 @@ module ActiveCipherStorage
|
|
|
97
100
|
seq = 0
|
|
98
101
|
done = false
|
|
99
102
|
until done
|
|
100
|
-
chunk = input_io.read(@
|
|
101
|
-
done = chunk.bytesize < @
|
|
103
|
+
chunk = input_io.read(@chunk_size) || "".b
|
|
104
|
+
done = chunk.bytesize < @chunk_size
|
|
102
105
|
seq += 1
|
|
103
106
|
frame_seq = done ? Format::FINAL_SEQ : seq
|
|
104
107
|
iv = SecureRandom.random_bytes(Format::IV_SIZE)
|
|
@@ -107,7 +110,7 @@ module ActiveCipherStorage
|
|
|
107
110
|
ct = chunk.empty? ? c.final : (c.update(chunk.b) + c.final)
|
|
108
111
|
Format.write_chunk(buffer, seq: frame_seq, iv: iv, ciphertext: ct, auth_tag: c.auth_tag)
|
|
109
112
|
|
|
110
|
-
next unless buffer.pos >= @
|
|
113
|
+
next unless buffer.pos >= @chunk_size || done
|
|
111
114
|
|
|
112
115
|
buffer.rewind
|
|
113
116
|
part_number += 1
|
|
@@ -125,7 +128,7 @@ module ActiveCipherStorage
|
|
|
125
128
|
def decrypt_io(io)
|
|
126
129
|
header = Format.read_header(io)
|
|
127
130
|
io.rewind
|
|
128
|
-
header.chunked ? StreamCipher.new(@config).decrypt_to_io(io)
|
|
131
|
+
header.chunked ? StreamCipher.new(config: @config, chunk_size: header.chunk_size).decrypt_to_io(io)
|
|
129
132
|
: StringIO.new(Cipher.new(@config).decrypt(io))
|
|
130
133
|
end
|
|
131
134
|
|
|
@@ -149,7 +152,7 @@ module ActiveCipherStorage
|
|
|
149
152
|
|
|
150
153
|
def validate_multipart_chunk_size!
|
|
151
154
|
min_size = Configuration::MINIMUM_S3_MULTIPART_PART_SIZE
|
|
152
|
-
return if @
|
|
155
|
+
return if @chunk_size >= min_size
|
|
153
156
|
|
|
154
157
|
raise ArgumentError,
|
|
155
158
|
"chunk_size must be at least 5 MiB for S3 multipart uploads"
|
|
@@ -235,6 +238,7 @@ module ActiveCipherStorage
|
|
|
235
238
|
def drain_frames(&block)
|
|
236
239
|
until @done
|
|
237
240
|
break if @buffer.bytesize < FRAME_PREFIX_SIZE
|
|
241
|
+
# Length prefix at byte 16: seq(4) + iv(12).
|
|
238
242
|
ct_len = @buffer.byteslice(16, 4).unpack1("N")
|
|
239
243
|
frame_size = FRAME_PREFIX_SIZE + ct_len + Format::AUTH_TAG_SIZE
|
|
240
244
|
break if @buffer.bytesize < frame_size
|
|
@@ -5,11 +5,10 @@ module ActiveCipherStorage
|
|
|
5
5
|
# encrypted => true
|
|
6
6
|
# cipher_version => Integer (Format::VERSION)
|
|
7
7
|
# provider_id => String (e.g. "aws_kms", "env")
|
|
8
|
-
# kms_key_id => String (CMK ARN,
|
|
8
|
+
# kms_key_id => String (CMK ARN, provider key identifier, or nil)
|
|
9
9
|
#
|
|
10
|
-
# These are for operational visibility
|
|
11
|
-
#
|
|
12
|
-
# authoritative source for decryption.
|
|
10
|
+
# These are for operational visibility and auditing. The encrypted file header
|
|
11
|
+
# is always the authoritative source for decryption.
|
|
13
12
|
module BlobMetadata
|
|
14
13
|
def self.write(storage_key, provider)
|
|
15
14
|
return unless active_storage_available?
|
|
@@ -25,10 +24,8 @@ module ActiveCipherStorage
|
|
|
25
24
|
"kms_key_id" => provider.key_id
|
|
26
25
|
).compact
|
|
27
26
|
)
|
|
28
|
-
rescue => e
|
|
29
|
-
|
|
30
|
-
"[ActiveCipherStorage] Could not write blob metadata for #{storage_key}: #{e.message}"
|
|
31
|
-
)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
log_metadata_failure(storage_key, "write", e)
|
|
32
29
|
end
|
|
33
30
|
|
|
34
31
|
def self.write_plaintext(storage_key)
|
|
@@ -45,28 +42,8 @@ module ActiveCipherStorage
|
|
|
45
42
|
"kms_key_id" => nil
|
|
46
43
|
).compact
|
|
47
44
|
)
|
|
48
|
-
rescue => e
|
|
49
|
-
|
|
50
|
-
"[ActiveCipherStorage] Could not write plaintext blob metadata for #{storage_key}: #{e.message}"
|
|
51
|
-
)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def self.update_after_rotation(storage_key, new_provider)
|
|
55
|
-
return unless active_storage_available?
|
|
56
|
-
|
|
57
|
-
blob = ActiveStorage::Blob.find_by(key: storage_key)
|
|
58
|
-
return unless blob
|
|
59
|
-
|
|
60
|
-
blob.update_columns(
|
|
61
|
-
metadata: blob.metadata.merge(
|
|
62
|
-
"provider_id" => new_provider.provider_id,
|
|
63
|
-
"kms_key_id" => new_provider.key_id
|
|
64
|
-
).compact
|
|
65
|
-
)
|
|
66
|
-
rescue => e
|
|
67
|
-
ActiveCipherStorage.configuration.logger.warn(
|
|
68
|
-
"[ActiveCipherStorage] Could not update rotation metadata for #{storage_key}: #{e.message}"
|
|
69
|
-
)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
log_metadata_failure(storage_key, "write_plaintext", e)
|
|
70
47
|
end
|
|
71
48
|
|
|
72
49
|
# Returns the metadata hash for a blob, or nil if AR is unavailable.
|
|
@@ -75,29 +52,21 @@ module ActiveCipherStorage
|
|
|
75
52
|
ActiveStorage::Blob.find_by(key: storage_key)&.metadata
|
|
76
53
|
end
|
|
77
54
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
# Yields each matching blob.
|
|
81
|
-
#
|
|
82
|
-
# For large tables, add a DB-level index on `metadata->>'kms_key_id'`
|
|
83
|
-
# and narrow the scope before passing to this method.
|
|
84
|
-
def self.blobs_for(provider)
|
|
85
|
-
return enum_for(:blobs_for, provider) unless block_given?
|
|
86
|
-
return unless active_storage_available?
|
|
55
|
+
private_class_method def self.log_metadata_failure(storage_key, operation, error)
|
|
56
|
+
raise error if reraise_metadata_errors?
|
|
87
57
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
next if provider.key_id && meta["kms_key_id"] != provider.key_id
|
|
58
|
+
ActiveCipherStorage.configuration.logger.warn(
|
|
59
|
+
"[ActiveCipherStorage] Blob metadata failed (#{operation}) for #{storage_key}: #{error.message}"
|
|
60
|
+
)
|
|
61
|
+
end
|
|
93
62
|
|
|
94
|
-
|
|
95
|
-
|
|
63
|
+
private_class_method def self.reraise_metadata_errors?
|
|
64
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
|
|
96
65
|
end
|
|
97
66
|
|
|
98
67
|
private_class_method def self.active_storage_available?
|
|
99
68
|
defined?(ActiveStorage::Blob) && ActiveStorage::Blob.table_exists?
|
|
100
|
-
rescue
|
|
69
|
+
rescue StandardError
|
|
101
70
|
false
|
|
102
71
|
end
|
|
103
72
|
end
|
|
@@ -6,19 +6,21 @@ module ActiveCipherStorage
|
|
|
6
6
|
# Supported algorithm identifiers.
|
|
7
7
|
ALGORITHMS = %w[aes-256-gcm].freeze
|
|
8
8
|
|
|
9
|
-
#
|
|
10
|
-
#
|
|
9
|
+
# Minimum S3 multipart part size (except the last part). Streaming encryptors
|
|
10
|
+
# should use chunk_size >= this value when using S3 multipart.
|
|
11
11
|
MINIMUM_S3_MULTIPART_PART_SIZE = 5 * 1024 * 1024
|
|
12
|
-
|
|
12
|
+
# Default plaintext bytes per chunk when a caller does not pass chunk_size.
|
|
13
|
+
DEFAULT_CHUNK_SIZE = MINIMUM_S3_MULTIPART_PART_SIZE
|
|
13
14
|
|
|
14
15
|
attr_reader :config
|
|
15
16
|
|
|
16
17
|
def initialize
|
|
17
18
|
@config = ActiveSupport::OrderedOptions.new
|
|
18
19
|
self.algorithm = "aes-256-gcm"
|
|
19
|
-
self.chunk_size = DEFAULT_CHUNK_SIZE
|
|
20
20
|
self.encrypt_uploads = true
|
|
21
21
|
self.logger = Logger.new($stdout, level: Logger::WARN)
|
|
22
|
+
@provider_input = nil
|
|
23
|
+
@provider = nil
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
def algorithm
|
|
@@ -29,14 +31,6 @@ module ActiveCipherStorage
|
|
|
29
31
|
config.algorithm = value
|
|
30
32
|
end
|
|
31
33
|
|
|
32
|
-
def chunk_size
|
|
33
|
-
config.chunk_size
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def chunk_size=(value)
|
|
37
|
-
config.chunk_size = value
|
|
38
|
-
end
|
|
39
|
-
|
|
40
34
|
def encrypt_uploads
|
|
41
35
|
config.encrypt_uploads
|
|
42
36
|
end
|
|
@@ -53,19 +47,33 @@ module ActiveCipherStorage
|
|
|
53
47
|
config.logger = value
|
|
54
48
|
end
|
|
55
49
|
|
|
50
|
+
# Keyword arguments for built-in providers (`:env`, `:aws_kms`, `"aws:kms"`, etc.),
|
|
51
|
+
# passed through to EnvProvider / AwsKmsProvider.
|
|
52
|
+
def provider_options
|
|
53
|
+
config.provider_options ||= ActiveSupport::OrderedOptions.new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
56
|
def provider
|
|
57
|
-
|
|
57
|
+
@provider ||= resolve_provider_input
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
#
|
|
60
|
+
# Sets the KMS provider. Use a shorthand symbol or string, or pass a custom
|
|
61
|
+
# Providers::Base instance:
|
|
62
|
+
#
|
|
63
|
+
# config.provider = :env
|
|
64
|
+
# config.provider = :aws_kms
|
|
65
|
+
# config.provider = "aws:kms"
|
|
66
|
+
# config.provider_options[:key_id] = "arn:..." # optional; see provider docs
|
|
67
|
+
# config.provider = MyKmsProvider.new
|
|
61
68
|
def provider=(value)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
when Providers::Base then value
|
|
69
|
+
@provider = nil
|
|
70
|
+
@provider_input = case value
|
|
71
|
+
when Providers::Base, Symbol, String, NilClass then value
|
|
65
72
|
else
|
|
66
73
|
raise ArgumentError,
|
|
67
|
-
"provider must be a Providers::Base instance
|
|
68
|
-
"
|
|
74
|
+
"provider must be a Providers::Base instance, " \
|
|
75
|
+
"a supported symbol/string (:env, :aws_kms, \"aws:kms\"), " \
|
|
76
|
+
"or nil — got #{value.inspect}"
|
|
69
77
|
end
|
|
70
78
|
end
|
|
71
79
|
|
|
@@ -78,8 +86,6 @@ module ActiveCipherStorage
|
|
|
78
86
|
"Supported: #{ALGORITHMS.join(', ')}"
|
|
79
87
|
end
|
|
80
88
|
|
|
81
|
-
raise ArgumentError, "chunk_size must be positive" unless chunk_size.positive?
|
|
82
|
-
|
|
83
89
|
return if [true, false].include?(encrypt_uploads)
|
|
84
90
|
|
|
85
91
|
raise ArgumentError, "encrypt_uploads must be true or false"
|
|
@@ -87,12 +93,39 @@ module ActiveCipherStorage
|
|
|
87
93
|
|
|
88
94
|
private
|
|
89
95
|
|
|
90
|
-
def
|
|
91
|
-
case
|
|
92
|
-
when
|
|
93
|
-
|
|
96
|
+
def resolve_provider_input
|
|
97
|
+
case @provider_input
|
|
98
|
+
when Providers::Base
|
|
99
|
+
@provider_input
|
|
100
|
+
when nil
|
|
101
|
+
nil
|
|
102
|
+
when Symbol, String
|
|
103
|
+
build_provider(parse_provider_name(@provider_input))
|
|
104
|
+
else
|
|
105
|
+
raise ArgumentError, "Unsupported provider #{@provider_input.inspect}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def parse_provider_name(name)
|
|
110
|
+
case name.to_s.strip.downcase
|
|
111
|
+
when "env" then :env
|
|
112
|
+
when "aws:kms", "aws_kms", "aws-kms", "kms" then :aws_kms
|
|
113
|
+
else
|
|
114
|
+
raise ArgumentError,
|
|
115
|
+
"Unknown provider #{name.inspect}; use :env, :aws_kms, \"aws:kms\", " \
|
|
116
|
+
"or a Providers::Base subclass instance"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build_provider(kind)
|
|
121
|
+
opts = provider_options.to_h.symbolize_keys.compact
|
|
122
|
+
case kind
|
|
123
|
+
when :env
|
|
124
|
+
Providers::EnvProvider.new(**opts)
|
|
125
|
+
when :aws_kms
|
|
126
|
+
Providers::AwsKmsProvider.new(**opts)
|
|
94
127
|
else
|
|
95
|
-
raise ArgumentError, "Unknown provider
|
|
128
|
+
raise ArgumentError, "Unknown provider kind #{kind.inspect}"
|
|
96
129
|
end
|
|
97
130
|
end
|
|
98
131
|
end
|
|
@@ -6,24 +6,8 @@ module ActiveCipherStorage
|
|
|
6
6
|
|
|
7
7
|
initializer "active_cipher_storage.setup" do
|
|
8
8
|
ActiveSupport.on_load(:active_storage) do
|
|
9
|
-
|
|
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)
|
|
9
|
+
require "active_storage/service/active_cipher_storage_service"
|
|
22
10
|
end
|
|
23
11
|
end
|
|
24
|
-
|
|
25
|
-
initializer "active_cipher_storage.log_subscriber" do
|
|
26
|
-
ActiveSupport::LogSubscriber.logger ||= Logger.new($stdout)
|
|
27
|
-
end
|
|
28
12
|
end
|
|
29
13
|
end
|
|
@@ -16,8 +16,8 @@ module ActiveCipherStorage
|
|
|
16
16
|
# Raised when the KMS provider cannot wrap/unwrap a data key.
|
|
17
17
|
class KeyManagementError < ProviderError; end
|
|
18
18
|
|
|
19
|
-
# Raised when a caller tries to use a feature the active provider
|
|
20
|
-
# implement (e.g.
|
|
19
|
+
# Raised when a caller tries to use a feature the active provider does not
|
|
20
|
+
# implement (e.g. direct upload URL when encryption is required).
|
|
21
21
|
class UnsupportedOperation < Error; end
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -25,11 +25,13 @@ module ActiveCipherStorage
|
|
|
25
25
|
|
|
26
26
|
SESSION_TTL = 24 * 3600
|
|
27
27
|
|
|
28
|
-
def initialize(s3_client:, bucket:, config: nil, store: nil
|
|
29
|
-
|
|
30
|
-
@
|
|
31
|
-
@
|
|
32
|
-
@
|
|
28
|
+
def initialize(s3_client:, bucket:, config: nil, store: nil,
|
|
29
|
+
chunk_size: Configuration::DEFAULT_CHUNK_SIZE)
|
|
30
|
+
@s3 = s3_client
|
|
31
|
+
@bucket = bucket
|
|
32
|
+
@config = config || ActiveCipherStorage.configuration
|
|
33
|
+
@store = store || MemorySessionStore.new
|
|
34
|
+
@chunk_size = Integer(chunk_size)
|
|
33
35
|
@config.validate!
|
|
34
36
|
validate_multipart_chunk_size!
|
|
35
37
|
end
|
|
@@ -46,7 +48,7 @@ module ActiveCipherStorage
|
|
|
46
48
|
version: Format::VERSION,
|
|
47
49
|
algorithm: Format::ALGO_AES256GCM,
|
|
48
50
|
chunked: true,
|
|
49
|
-
chunk_size: @
|
|
51
|
+
chunk_size: @chunk_size,
|
|
50
52
|
provider_id: @config.provider.provider_id,
|
|
51
53
|
encrypted_dek: dek_bundle[:encrypted_key]
|
|
52
54
|
))
|
|
@@ -75,9 +77,9 @@ module ActiveCipherStorage
|
|
|
75
77
|
session[:pending] = (session[:pending] +
|
|
76
78
|
build_frame(plaintext, session[:encrypted_dek], session[:seq])).b
|
|
77
79
|
|
|
78
|
-
while session[:pending].bytesize >= @
|
|
79
|
-
flush_part(session, session[:pending].byteslice(0, @
|
|
80
|
-
session[:pending] = (session[:pending].byteslice(@
|
|
80
|
+
while session[:pending].bytesize >= @chunk_size
|
|
81
|
+
flush_part(session, session[:pending].byteslice(0, @chunk_size))
|
|
82
|
+
session[:pending] = (session[:pending].byteslice(@chunk_size..) || "".b).b
|
|
81
83
|
end
|
|
82
84
|
|
|
83
85
|
save_session(session_id, session)
|
|
@@ -169,7 +171,7 @@ module ActiveCipherStorage
|
|
|
169
171
|
|
|
170
172
|
def validate_multipart_chunk_size!
|
|
171
173
|
min_size = Configuration::MINIMUM_S3_MULTIPART_PART_SIZE
|
|
172
|
-
return if @
|
|
174
|
+
return if @chunk_size >= min_size
|
|
173
175
|
|
|
174
176
|
raise ArgumentError,
|
|
175
177
|
"chunk_size must be at least 5 MiB for S3 multipart uploads"
|
|
@@ -5,14 +5,15 @@ module ActiveCipherStorage
|
|
|
5
5
|
|
|
6
6
|
PROVIDER_ID = "aws_kms"
|
|
7
7
|
|
|
8
|
-
def initialize(key_id
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@
|
|
14
|
-
@
|
|
15
|
-
@
|
|
8
|
+
def initialize(key_id:, region: nil, endpoint: nil, access_key_id: nil,
|
|
9
|
+
secret_access_key: nil, encryption_context: {}, client: nil)
|
|
10
|
+
@key_id = key_id
|
|
11
|
+
@region = region
|
|
12
|
+
@endpoint = endpoint
|
|
13
|
+
@access_key_id = access_key_id
|
|
14
|
+
@secret_access_key = secret_access_key
|
|
15
|
+
@encryption_context = encryption_context || {}
|
|
16
|
+
@client_override = client
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def provider_id = PROVIDER_ID
|
|
@@ -48,42 +49,25 @@ module ActiveCipherStorage
|
|
|
48
49
|
resp&.plaintext&.then { |k| zero_bytes!(k) }
|
|
49
50
|
end
|
|
50
51
|
|
|
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
52
|
private
|
|
79
53
|
|
|
80
54
|
def kms_client
|
|
81
55
|
@kms_client ||= begin
|
|
82
56
|
require "aws-sdk-kms"
|
|
83
|
-
@client_override
|
|
57
|
+
if @client_override
|
|
58
|
+
@client_override
|
|
59
|
+
else
|
|
60
|
+
opts = {
|
|
61
|
+
region: @region,
|
|
62
|
+
endpoint: @endpoint,
|
|
63
|
+
access_key_id: @access_key_id,
|
|
64
|
+
secret_access_key: @secret_access_key
|
|
65
|
+
}.compact
|
|
66
|
+
Aws::KMS::Client.new(**opts)
|
|
67
|
+
end
|
|
68
|
+
rescue LoadError
|
|
69
|
+
raise Errors::ProviderError, "aws-sdk-kms is required: add it to your Gemfile"
|
|
84
70
|
end
|
|
85
|
-
rescue LoadError
|
|
86
|
-
raise Errors::ProviderError, "aws-sdk-kms is required: add it to your Gemfile"
|
|
87
71
|
end
|
|
88
72
|
end
|
|
89
73
|
end
|
|
@@ -11,28 +11,17 @@ module ActiveCipherStorage
|
|
|
11
11
|
raise NotImplementedError, "#{self.class}#decrypt_data_key is not implemented"
|
|
12
12
|
end
|
|
13
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
14
|
# Short ASCII string embedded in every encrypted file header.
|
|
21
15
|
def provider_id
|
|
22
16
|
raise NotImplementedError, "#{self.class}#provider_id is not implemented"
|
|
23
17
|
end
|
|
24
18
|
|
|
25
19
|
# Stable identifier for the specific key material in use (e.g. CMK ARN,
|
|
26
|
-
# env var name). Stored in blob metadata for
|
|
27
|
-
#
|
|
20
|
+
# env var name). Stored in blob metadata for visibility. Return nil if
|
|
21
|
+
# your provider has no stable key identifier.
|
|
28
22
|
def key_id
|
|
29
23
|
nil
|
|
30
24
|
end
|
|
31
|
-
|
|
32
|
-
def rotate_data_key(encrypted_key)
|
|
33
|
-
raise Errors::UnsupportedOperation,
|
|
34
|
-
"#{self.class} does not support key rotation"
|
|
35
|
-
end
|
|
36
25
|
end
|
|
37
26
|
end
|
|
38
27
|
end
|
|
@@ -13,16 +13,14 @@ module ActiveCipherStorage
|
|
|
13
13
|
WRAP_IV_SIZE = 12
|
|
14
14
|
WRAP_TAG_SIZE = 16
|
|
15
15
|
|
|
16
|
-
def initialize(
|
|
17
|
-
@
|
|
18
|
-
@old_env_var = old_env_var
|
|
16
|
+
def initialize(encryption_key:)
|
|
17
|
+
@encryption_key = encryption_key
|
|
19
18
|
end
|
|
20
19
|
|
|
21
20
|
def provider_id = PROVIDER_ID
|
|
22
|
-
def key_id = @env_var
|
|
23
21
|
|
|
24
22
|
def generate_data_key
|
|
25
|
-
master = read_master_key
|
|
23
|
+
master = read_master_key
|
|
26
24
|
dek = SecureRandom.random_bytes(Cipher::KEY_SIZE)
|
|
27
25
|
{ plaintext_key: dek, encrypted_key: wrap_key(dek, master) }
|
|
28
26
|
ensure
|
|
@@ -30,34 +28,12 @@ module ActiveCipherStorage
|
|
|
30
28
|
end
|
|
31
29
|
|
|
32
30
|
def decrypt_data_key(encrypted_key)
|
|
33
|
-
master = read_master_key
|
|
31
|
+
master = read_master_key
|
|
34
32
|
unwrap_key(encrypted_key, master)
|
|
35
33
|
ensure
|
|
36
34
|
zero_bytes!(master)
|
|
37
35
|
end
|
|
38
36
|
|
|
39
|
-
def wrap_data_key(plaintext_dek)
|
|
40
|
-
master = read_master_key(@env_var)
|
|
41
|
-
wrap_key(plaintext_dek, master)
|
|
42
|
-
ensure
|
|
43
|
-
zero_bytes!(master)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def rotate_data_key(encrypted_key, old_provider: nil)
|
|
47
|
-
source = old_provider || begin
|
|
48
|
-
raise Errors::UnsupportedOperation,
|
|
49
|
-
"Supply :old_provider to rotate via EnvProvider" unless @old_env_var
|
|
50
|
-
EnvProvider.new(env_var: @old_env_var)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
plaintext_dek = source.decrypt_data_key(encrypted_key)
|
|
54
|
-
new_master = read_master_key(@env_var)
|
|
55
|
-
wrap_key(plaintext_dek, new_master)
|
|
56
|
-
ensure
|
|
57
|
-
zero_bytes!(plaintext_dek)
|
|
58
|
-
zero_bytes!(new_master)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
37
|
private
|
|
62
38
|
|
|
63
39
|
# Wrapped DEK: [12 IV][32 ciphertext][16 auth-tag] = 60 bytes
|
|
@@ -94,11 +70,12 @@ module ActiveCipherStorage
|
|
|
94
70
|
c
|
|
95
71
|
end
|
|
96
72
|
|
|
97
|
-
def read_master_key
|
|
98
|
-
encoded =
|
|
73
|
+
def read_master_key
|
|
74
|
+
encoded = @encryption_key
|
|
75
|
+
if encoded.nil? || encoded.empty?
|
|
99
76
|
raise Errors::ProviderError,
|
|
100
|
-
"
|
|
101
|
-
"Generate one with: ruby -rsecurerandom -e " \
|
|
77
|
+
"provider_options[:encryption_key] must be set. " \
|
|
78
|
+
"Generate one with: ruby -rsecurerandom -rbase64 -e " \
|
|
102
79
|
"'puts Base64.strict_encode64(SecureRandom.bytes(32))'"
|
|
103
80
|
end
|
|
104
81
|
|
|
@@ -106,12 +83,12 @@ module ActiveCipherStorage
|
|
|
106
83
|
Base64.strict_decode64(encoded)
|
|
107
84
|
rescue ArgumentError
|
|
108
85
|
raise Errors::ProviderError,
|
|
109
|
-
"
|
|
86
|
+
"provider_options[:encryption_key] must be Base64-encoded (strict, no line breaks)"
|
|
110
87
|
end
|
|
111
88
|
|
|
112
89
|
unless key.bytesize == MASTER_KEY_SIZE
|
|
113
90
|
raise Errors::ProviderError,
|
|
114
|
-
"
|
|
91
|
+
"provider_options[:encryption_key] must decode to exactly #{MASTER_KEY_SIZE} bytes " \
|
|
115
92
|
"(got #{key.bytesize})"
|
|
116
93
|
end
|
|
117
94
|
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
require "openssl"
|
|
2
2
|
require "securerandom"
|
|
3
|
+
require "stringio"
|
|
3
4
|
|
|
4
5
|
module ActiveCipherStorage
|
|
5
6
|
class StreamCipher
|
|
6
7
|
include KeyUtils
|
|
7
8
|
|
|
8
|
-
def initialize(config
|
|
9
|
+
def initialize(config: ActiveCipherStorage.configuration,
|
|
10
|
+
chunk_size: Configuration::DEFAULT_CHUNK_SIZE)
|
|
9
11
|
config.validate!
|
|
10
12
|
@config = config
|
|
11
13
|
@provider = config.provider
|
|
12
|
-
@chunk_size =
|
|
14
|
+
@chunk_size = Integer(chunk_size)
|
|
15
|
+
raise ArgumentError, "chunk_size must be positive" unless @chunk_size.positive?
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
def encrypt(input_io, output_io)
|
|
@@ -15,9 +15,7 @@ require_relative "active_cipher_storage/providers/aws_kms_provider"
|
|
|
15
15
|
require_relative "active_cipher_storage/cipher"
|
|
16
16
|
require_relative "active_cipher_storage/stream_cipher"
|
|
17
17
|
require_relative "active_cipher_storage/adapters/s3_adapter"
|
|
18
|
-
require_relative "active_cipher_storage/adapters/active_storage_service"
|
|
19
18
|
require_relative "active_cipher_storage/blob_metadata"
|
|
20
|
-
require_relative "active_cipher_storage/key_rotation"
|
|
21
19
|
require_relative "active_cipher_storage/multipart_upload"
|
|
22
20
|
|
|
23
21
|
# Rails Engine wires the service into ActiveStorage's service registry.
|