active_cipher_storage 1.0.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -6
- data/CONTRIBUTING.md +0 -1
- data/README.md +101 -123
- data/lib/active_cipher_storage/adapters/s3_adapter.rb +11 -7
- data/lib/active_cipher_storage/blob_metadata.rb +16 -47
- data/lib/active_cipher_storage/configuration.rb +59 -26
- data/lib/active_cipher_storage/engine.rb +1 -17
- data/lib/active_cipher_storage/errors.rb +2 -2
- data/lib/active_cipher_storage/multipart_upload.rb +12 -10
- data/lib/active_cipher_storage/providers/aws_kms_provider.rb +22 -38
- data/lib/active_cipher_storage/providers/base.rb +2 -13
- data/lib/active_cipher_storage/providers/env_provider.rb +11 -34
- data/lib/active_cipher_storage/stream_cipher.rb +5 -2
- data/lib/active_cipher_storage/version.rb +1 -1
- data/lib/active_cipher_storage.rb +0 -2
- data/lib/active_storage/service/active_cipher_storage_service.rb +181 -4
- metadata +2 -4
- data/lib/active_cipher_storage/adapters/active_storage_service.rb +0 -140
- data/lib/active_cipher_storage/key_rotation.rb +0 -121
|
@@ -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.
|
|
@@ -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
|