Class: ActiveStorageEncryption::EncryptedDiskService

Inherits:
ActiveStorage::Service::DiskService
  • Object
show all
Includes:
PrivateUrlPolicy
Defined in:
lib/active_storage_encryption/encrypted_disk_service.rb

Overview

Provides a local encrypted store for ActiveStorage blobs. Configure it like so:

local_encrypted:
  service: EncryptedDisk
  root: <%= Rails.root.join("storage/encrypted") %>
  private_url_policy: stream

Defined Under Namespace

Classes: V1Scheme, V2Scheme

Constant Summary collapse

FILENAME_EXTENSIONS_PER_SCHEME =
{
  ".encrypted-v1" => "V1Scheme",
  ".encrypted-v2" => "V2Scheme"
}

Constants included from PrivateUrlPolicy

PrivateUrlPolicy::DEFAULT_POLICY

Instance Method Summary collapse

Methods included from PrivateUrlPolicy

#private_url_for_streaming_via_controller, #private_url_policy, #private_url_policy=

Constructor Details

#initialize(public: false, **options_for_disk_storage) ⇒ EncryptedDiskService

Returns a new instance of EncryptedDiskService.

Raises:

  • (ArgumentError)


29
30
31
32
# File 'lib/active_storage_encryption/encrypted_disk_service.rb', line 29

def initialize(public: false, **options_for_disk_storage)
  raise ArgumentError, "encrypted files cannot be served via a public URL or a CDN" if public
  super
end

Instance Method Details

#compose(source_keys, destination_key, source_encryption_keys:, encryption_key:) ⇒ Object



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/active_storage_encryption/encrypted_disk_service.rb', line 108

def compose(source_keys, destination_key, source_encryption_keys:, encryption_key:, **)
  if source_keys.length != source_encryption_keys.length
    raise ArgumentError, "With #{source_keys.length} keys to compose there should be exactly as many source_encryption_keys, but got #{source_encryption_keys.length}"
  end
  File.open(make_path_for(destination_key), "wb") do |destination_file|
    writing_scheme = create_scheme(destination_key, encryption_key)
    writing_scheme.streaming_encrypt(into_ciphertext_io: destination_file) do |writable|
      source_keys.zip(source_encryption_keys).each do |(source_key, encryption_key_for_source)|
        File.open(path_for(source_key), "rb") do |source_file|
          reading_scheme = create_scheme(source_key, encryption_key_for_source)
          reading_scheme.streaming_decrypt(from_ciphertext_io: source_file, into_plaintext_io: writable)
        end
      end
    end
  end
end

#download(key, encryption_key:, &block) ⇒ Object



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/active_storage_encryption/encrypted_disk_service.rb', line 44

def download(key, encryption_key:, &block)
  if block_given?
    instrument :streaming_download, key: key do
      stream key, encryption_key, &block
    end
  else
    instrument :download, key: key do
      (+"").b.tap do |buf|
        download(key, encryption_key: encryption_key) do |data|
          buf << data
        end
      end
    end
  end
end

#download_chunk(key, range, encryption_key:) ⇒ Object



60
61
62
63
64
65
66
67
68
69
# File 'lib/active_storage_encryption/encrypted_disk_service.rb', line 60

def download_chunk(key, range, encryption_key:)
  instrument :download_chunk, key: key, range: range do
    scheme = create_scheme(key, encryption_key)
    File.open(path_for(key), "rb") do |file|
      scheme.decrypt_range(from_ciphertext_io: file, range:)
    end
  rescue Errno::ENOENT
    raise ActiveStorage::FileNotFoundError
  end
end

#encrypted?Boolean

This lets the Blob encryption key methods know that this storage service must use encryption

Returns:

  • (Boolean)


27
# File 'lib/active_storage_encryption/encrypted_disk_service.rb', line 27

def encrypted? = true

#exist?(key) ⇒ Boolean

Returns:

  • (Boolean)


104
105
106
# File 'lib/active_storage_encryption/encrypted_disk_service.rb', line 104

def exist?(key)
  File.exist?(path_for(key))
end

#headers_for_direct_upload(key, content_type:, encryption_key:, checksum:) ⇒ Object



125
126
127
128
129
130
131
132
# File 'lib/active_storage_encryption/encrypted_disk_service.rb', line 125

def headers_for_direct_upload(key, content_type:, encryption_key:, checksum:, **)
  # Both GCP and AWS require the key to be provided in the headers, together with the
  # upload PUT request. This is not needed for the encrypted disk service, but it is
  # useful to check it does get passed to the HTTP client and then to the upload -
  # our controller extension will verify that this header is present, and fail if
  # it is not in place.
  super.merge!("x-active-storage-encryption-key" => Base64.strict_encode64(encryption_key), "content-md5" => checksum)
end

#headers_for_private_download(key, encryption_key:) ⇒ Object



134
135
136
# File 'lib/active_storage_encryption/encrypted_disk_service.rb', line 134

def headers_for_private_download(key, encryption_key:, **)
  {"x-active-storage-encryption-key" => Base64.strict_encode64(encryption_key)}
end

#path_for(key) ⇒ Object

:nodoc:



93
94
95
96
97
98
99
100
101
102
# File 'lib/active_storage_encryption/encrypted_disk_service.rb', line 93

def path_for(key) # :nodoc:
  # The extension indicates what encryption scheme the file will be using. This method
  # gets used two ways - to get a path for a new object, and to get a path for an existing object.
  # If an existing object is found, we need to return the path for the highest version of that
  # object. If we want to create one - we always return the latest one.
  glob_pattern = File.join(root, folder_for(key), key + ".encrypted-*")
  last_existing_path = Dir.glob(glob_pattern).max
  path_for_new_file = File.join(root, folder_for(key), key + FILENAME_EXTENSIONS_PER_SCHEME.keys.last)
  last_existing_path || path_for_new_file
end

#upload(key, io, encryption_key:, checksum: nil) ⇒ Object



34
35
36
37
38
39
40
41
42
# File 'lib/active_storage_encryption/encrypted_disk_service.rb', line 34

def upload(key, io, encryption_key:, checksum: nil, **)
  instrument :upload, key: key, checksum: checksum do
    scheme = create_scheme(key, encryption_key)
    File.open(make_path_for(key), "wb") do |file|
      scheme.streaming_encrypt(from_plaintext_io: io, into_ciphertext_io: file)
    end
    ensure_integrity_of(key, checksum, encryption_key) if checksum
  end
end

#url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, encryption_key:, custom_metadata: {}) ⇒ Object



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/active_storage_encryption/encrypted_disk_service.rb', line 71

def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, encryption_key:, custom_metadata: {})
  instrument :url, key: key do |payload|
    upload_token = ActiveStorage.verifier.generate(
      {
        key: key,
        content_type: content_type,
        content_length: content_length,
        encryption_key_sha256: Digest::SHA256.base64digest(encryption_key),
        checksum: checksum,
        service_name: name
      },
      expires_in: expires_in,
      purpose: :encrypted_put
    )

    url_helpers = ActiveStorageEncryption::Engine.routes.url_helpers
    url_helpers.encrypted_blob_put_url(upload_token, url_options).tap do |generated_url|
      payload[:url] = generated_url
    end
  end
end