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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/CONTRIBUTING.md +46 -0
- data/LICENSE +21 -0
- data/README.md +644 -0
- data/SECURITY.md +35 -0
- data/active_cipher_storage.gemspec +55 -0
- data/lib/active_cipher_storage/adapters/active_storage_service.rb +129 -0
- data/lib/active_cipher_storage/adapters/s3_adapter.rb +252 -0
- data/lib/active_cipher_storage/blob_metadata.rb +84 -0
- data/lib/active_cipher_storage/cipher.rb +97 -0
- data/lib/active_cipher_storage/configuration.rb +57 -0
- data/lib/active_cipher_storage/engine.rb +29 -0
- data/lib/active_cipher_storage/errors.rb +31 -0
- data/lib/active_cipher_storage/format.rb +126 -0
- data/lib/active_cipher_storage/key_rotation.rb +121 -0
- data/lib/active_cipher_storage/key_utils.rb +12 -0
- data/lib/active_cipher_storage/multipart_upload.rb +190 -0
- data/lib/active_cipher_storage/providers/aws_kms_provider.rb +90 -0
- data/lib/active_cipher_storage/providers/base.rb +38 -0
- data/lib/active_cipher_storage/providers/env_provider.rb +122 -0
- data/lib/active_cipher_storage/stream_cipher.rb +102 -0
- data/lib/active_cipher_storage/version.rb +3 -0
- data/lib/active_cipher_storage.rb +43 -0
- data/lib/active_storage/service/active_cipher_storage_service.rb +10 -0
- metadata +224 -0
|
@@ -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
|