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.
@@ -6,19 +6,21 @@ module ActiveCipherStorage
6
6
  # Supported algorithm identifiers.
7
7
  ALGORITHMS = %w[aes-256-gcm].freeze
8
8
 
9
- # Bytes per plaintext chunk in streaming mode (default 5 MiB matches the
10
- # minimum S3 multipart part size, so each chunk maps to exactly one part).
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
- DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024
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
- config.provider
57
+ @provider ||= resolve_provider_input
58
58
  end
59
59
 
60
- # Accept a provider instance or a symbol shorthand (:env, :aws_kms).
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
- config.provider = case value
63
- when Symbol then resolve_provider(value)
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 or " \
68
- "one of :env, :aws_kms got #{value.inspect}"
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 resolve_provider(sym)
91
- case sym
92
- when :env then Providers::EnvProvider.new
93
- when :aws_kms then Providers::AwsKmsProvider.new
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 shorthand: #{sym.inspect}"
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
- # 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)
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 doesn't
20
- # implement (e.g. key rotation on EnvProvider).
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
- @s3 = s3_client
30
- @bucket = bucket
31
- @config = config || ActiveCipherStorage.configuration
32
- @store = store || MemorySessionStore.new
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: @config.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 >= @config.chunk_size
79
- flush_part(session, session[:pending].byteslice(0, @config.chunk_size))
80
- session[:pending] = (session[:pending].byteslice(@config.chunk_size..) || "".b).b
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 @config.chunk_size >= min_size
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: 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
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 || Aws::KMS::Client.new(**{ region: @region }.compact)
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 rotation queries.
27
- # Returns nil for providers where key identity is not meaningful.
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(env_var: "ACTIVE_CIPHER_MASTER_KEY", old_env_var: nil)
17
- @env_var = env_var
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(@env_var)
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(@env_var)
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(var_name)
98
- encoded = ENV.fetch(var_name) do
73
+ def read_master_key
74
+ encoded = @encryption_key
75
+ if encoded.nil? || encoded.empty?
99
76
  raise Errors::ProviderError,
100
- "Environment variable #{var_name.inspect} is not set. " \
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
- "#{var_name} must be Base64-encoded (strict, no line breaks)"
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
- "#{var_name} must decode to exactly #{MASTER_KEY_SIZE} bytes " \
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 = ActiveCipherStorage.configuration)
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 = config.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)
@@ -1,3 +1,3 @@
1
1
  module ActiveCipherStorage
2
- VERSION = "1.0.3"
2
+ VERSION = "2.0.0"
3
3
  end
@@ -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/adapters/active_storage_service"
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
- class Service
6
- unless const_defined?(:ActiveCipherStorageService, false)
7
- ActiveCipherStorageService = ::ActiveCipherStorage::Adapters::ActiveStorageService
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