familia 2.0.0.pre3 → 2.0.0.pre5
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/.gitignore +3 -0
- data/.rubocop_todo.yml +17 -17
- data/CLAUDE.md +3 -3
- data/Gemfile +5 -1
- data/Gemfile.lock +18 -3
- data/README.md +36 -157
- data/TEST_COVERAGE.md +40 -0
- data/docs/overview.md +359 -0
- data/docs/wiki/API-Reference.md +270 -0
- data/docs/wiki/Encrypted-Fields-Overview.md +64 -0
- data/docs/wiki/Home.md +49 -0
- data/docs/wiki/Implementation-Guide.md +183 -0
- data/docs/wiki/Security-Model.md +143 -0
- data/lib/familia/base.rb +18 -27
- data/lib/familia/connection.rb +6 -5
- data/lib/familia/{datatype → data_type}/commands.rb +2 -5
- data/lib/familia/{datatype → data_type}/serialization.rb +8 -10
- data/lib/familia/{datatype → data_type}/types/hashkey.rb +2 -2
- data/lib/familia/{datatype → data_type}/types/list.rb +17 -18
- data/lib/familia/{datatype → data_type}/types/sorted_set.rb +17 -17
- data/lib/familia/{datatype → data_type}/types/string.rb +2 -1
- data/lib/familia/{datatype → data_type}/types/unsorted_set.rb +17 -18
- data/lib/familia/{datatype.rb → data_type.rb} +10 -12
- data/lib/familia/encryption/manager.rb +102 -0
- data/lib/familia/encryption/provider.rb +49 -0
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +103 -0
- data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +184 -0
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +118 -0
- data/lib/familia/encryption/registry.rb +50 -0
- data/lib/familia/encryption.rb +178 -0
- data/lib/familia/encryption_request_cache.rb +68 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +153 -0
- data/lib/familia/features/encrypted_fields.rb +28 -0
- data/lib/familia/features/expiration.rb +107 -77
- data/lib/familia/features/quantization.rb +5 -9
- data/lib/familia/features/relatable_objects.rb +2 -4
- data/lib/familia/features/safe_dump.rb +14 -17
- data/lib/familia/features/transient_fields/redacted_string.rb +159 -0
- data/lib/familia/features/transient_fields/single_use_redacted_string.rb +62 -0
- data/lib/familia/features/transient_fields/transient_field_type.rb +139 -0
- data/lib/familia/features/transient_fields.rb +47 -0
- data/lib/familia/features.rb +40 -24
- data/lib/familia/field_type.rb +270 -0
- data/lib/familia/horreum/connection.rb +8 -11
- data/lib/familia/horreum/{commands.rb → database_commands.rb} +7 -19
- data/lib/familia/horreum/definition_methods.rb +453 -0
- data/lib/familia/horreum/{class_methods.rb → management_methods.rb} +19 -229
- data/lib/familia/horreum/serialization.rb +46 -18
- data/lib/familia/horreum/settings.rb +10 -2
- data/lib/familia/horreum/utils.rb +9 -10
- data/lib/familia/horreum.rb +18 -10
- data/lib/familia/logging.rb +14 -14
- data/lib/familia/settings.rb +39 -3
- data/lib/familia/utils.rb +45 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +2 -1
- data/try/core/base_enhancements_try.rb +115 -0
- data/try/core/connection_try.rb +0 -1
- data/try/core/errors_try.rb +0 -1
- data/try/core/familia_extended_try.rb +3 -4
- data/try/core/familia_try.rb +0 -1
- data/try/core/pools_try.rb +2 -2
- data/try/core/secure_identifier_try.rb +0 -1
- data/try/core/settings_try.rb +0 -1
- data/try/core/utils_try.rb +0 -1
- data/try/{datatypes → data_types}/boolean_try.rb +1 -2
- data/try/{datatypes → data_types}/datatype_base_try.rb +2 -3
- data/try/{datatypes → data_types}/hash_try.rb +1 -2
- data/try/{datatypes → data_types}/list_try.rb +1 -2
- data/try/{datatypes → data_types}/set_try.rb +1 -2
- data/try/{datatypes → data_types}/sorted_set_try.rb +1 -2
- data/try/{datatypes → data_types}/string_try.rb +1 -2
- data/try/debugging/README.md +32 -0
- data/try/debugging/cache_behavior_tracer.rb +91 -0
- data/try/debugging/encryption_method_tracer.rb +138 -0
- data/try/debugging/provider_diagnostics.rb +110 -0
- data/try/edge_cases/hash_symbolization_try.rb +0 -1
- data/try/edge_cases/json_serialization_try.rb +0 -1
- data/try/edge_cases/reserved_keywords_try.rb +42 -11
- data/try/encryption/config_persistence_try.rb +192 -0
- data/try/encryption/encryption_core_try.rb +328 -0
- data/try/encryption/instance_variable_scope_try.rb +31 -0
- data/try/encryption/module_loading_try.rb +28 -0
- data/try/encryption/providers/aes_gcm_provider_try.rb +178 -0
- data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +169 -0
- data/try/encryption/roundtrip_validation_try.rb +28 -0
- data/try/encryption/secure_memory_handling_try.rb +125 -0
- data/try/features/encrypted_fields_core_try.rb +117 -0
- data/try/features/encrypted_fields_integration_try.rb +220 -0
- data/try/features/encrypted_fields_no_cache_security_try.rb +205 -0
- data/try/features/encrypted_fields_security_try.rb +370 -0
- data/try/features/encryption_fields/aad_protection_try.rb +53 -0
- data/try/features/encryption_fields/context_isolation_try.rb +120 -0
- data/try/features/encryption_fields/error_conditions_try.rb +116 -0
- data/try/features/encryption_fields/fresh_key_derivation_try.rb +122 -0
- data/try/features/encryption_fields/fresh_key_try.rb +163 -0
- data/try/features/encryption_fields/key_rotation_try.rb +117 -0
- data/try/features/encryption_fields/memory_security_try.rb +37 -0
- data/try/features/encryption_fields/missing_current_key_version_try.rb +23 -0
- data/try/features/encryption_fields/nonce_uniqueness_try.rb +54 -0
- data/try/features/encryption_fields/thread_safety_try.rb +199 -0
- data/try/features/expiration_try.rb +0 -1
- data/try/features/feature_dependencies_try.rb +159 -0
- data/try/features/quantization_try.rb +0 -1
- data/try/features/real_feature_integration_try.rb +148 -0
- data/try/features/relatable_objects_try.rb +0 -1
- data/try/features/safe_dump_advanced_try.rb +0 -1
- data/try/features/safe_dump_try.rb +0 -1
- data/try/features/transient_fields/redacted_string_try.rb +248 -0
- data/try/features/transient_fields/refresh_reset_try.rb +164 -0
- data/try/features/transient_fields/simple_refresh_test.rb +50 -0
- data/try/features/transient_fields/single_use_redacted_string_try.rb +310 -0
- data/try/features/transient_fields_core_try.rb +181 -0
- data/try/features/transient_fields_integration_try.rb +260 -0
- data/try/helpers/test_helpers.rb +42 -0
- data/try/horreum/base_try.rb +157 -3
- data/try/horreum/class_methods_try.rb +27 -36
- data/try/horreum/enhanced_conflict_handling_try.rb +176 -0
- data/try/horreum/field_categories_try.rb +118 -0
- data/try/horreum/field_definition_try.rb +96 -0
- data/try/horreum/initialization_try.rb +0 -1
- data/try/horreum/relations_try.rb +0 -1
- data/try/horreum/serialization_persistent_fields_try.rb +165 -0
- data/try/horreum/serialization_try.rb +2 -3
- data/try/memory/memory_basic_test.rb +73 -0
- data/try/memory/memory_detailed_test.rb +121 -0
- data/try/memory/memory_docker_ruby_dump.sh +80 -0
- data/try/memory/memory_search_for_string.rb +83 -0
- data/try/memory/test_actual_redactedstring_protection.rb +38 -0
- data/try/models/customer_safe_dump_try.rb +0 -1
- data/try/models/customer_try.rb +0 -1
- data/try/models/datatype_base_try.rb +1 -2
- data/try/models/familia_object_try.rb +0 -1
- metadata +85 -18
@@ -0,0 +1,50 @@
|
|
1
|
+
# lib/familia/encryption/registry.rb
|
2
|
+
|
3
|
+
module Familia
|
4
|
+
module Encryption
|
5
|
+
# Registry pattern for managing encryption providers
|
6
|
+
class Registry
|
7
|
+
class << self
|
8
|
+
def providers
|
9
|
+
@providers ||= {}
|
10
|
+
end
|
11
|
+
|
12
|
+
def register(provider_class)
|
13
|
+
return unless provider_class.available?
|
14
|
+
|
15
|
+
providers[provider_class::ALGORITHM] = provider_class
|
16
|
+
end
|
17
|
+
|
18
|
+
def get(algorithm)
|
19
|
+
provider_class = providers[algorithm]
|
20
|
+
raise EncryptionError, "Unsupported algorithm: #{algorithm}" unless provider_class
|
21
|
+
|
22
|
+
provider_class.new
|
23
|
+
end
|
24
|
+
|
25
|
+
def default_provider
|
26
|
+
# Select provider with highest priority
|
27
|
+
@default_provider ||= begin
|
28
|
+
available = providers.values.select(&:available?)
|
29
|
+
available.max_by(&:priority)&.new
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def reset_default_provider!
|
34
|
+
@default_provider = nil
|
35
|
+
end
|
36
|
+
|
37
|
+
def available_algorithms
|
38
|
+
providers.keys
|
39
|
+
end
|
40
|
+
|
41
|
+
# Auto-register known providers
|
42
|
+
def setup!
|
43
|
+
register(Providers::XChaCha20Poly1305Provider)
|
44
|
+
register(Providers::AESGCMProvider)
|
45
|
+
# Future: register(Providers::ChaCha20Poly1305Provider)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,178 @@
|
|
1
|
+
# lib/familia/encryption.rb
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
require 'json'
|
5
|
+
require 'openssl'
|
6
|
+
|
7
|
+
# Provider system components
|
8
|
+
require_relative 'encryption/provider'
|
9
|
+
require_relative 'encryption/providers/xchacha20_poly1305_provider'
|
10
|
+
require_relative 'encryption/providers/aes_gcm_provider'
|
11
|
+
require_relative 'encryption/registry'
|
12
|
+
require_relative 'encryption/manager'
|
13
|
+
|
14
|
+
module Familia
|
15
|
+
class EncryptionError < StandardError; end
|
16
|
+
|
17
|
+
module Encryption
|
18
|
+
EncryptedData = Data.define(:algorithm, :nonce, :ciphertext, :auth_tag, :key_version)
|
19
|
+
|
20
|
+
# Smart facade with provider selection and field-specific encryption
|
21
|
+
#
|
22
|
+
# Usage in EncryptedFieldType can now be more flexible:
|
23
|
+
#
|
24
|
+
# module Familia
|
25
|
+
# class EncryptedFieldType < FieldType
|
26
|
+
# attr_reader :algorithm # Optional algorithm override
|
27
|
+
#
|
28
|
+
# def initialize(name, aad_fields: [], algorithm: nil, **options)
|
29
|
+
# super(name, **options.merge(on_conflict: :raise))
|
30
|
+
# @aad_fields = Array(aad_fields).freeze
|
31
|
+
# @algorithm = algorithm # Use specific algorithm for this field
|
32
|
+
# end
|
33
|
+
#
|
34
|
+
# def encrypt_value(record, value)
|
35
|
+
# context = build_context(record)
|
36
|
+
# additional_data = build_aad(record)
|
37
|
+
#
|
38
|
+
# if @algorithm
|
39
|
+
# # Use specific algorithm for this field
|
40
|
+
# Familia::Encryption.encrypt_with(@algorithm, value,
|
41
|
+
# context: context,
|
42
|
+
# additional_data: additional_data)
|
43
|
+
# else
|
44
|
+
# # Use default best algorithm
|
45
|
+
# Familia::Encryption.encrypt(value,
|
46
|
+
# context: context,
|
47
|
+
# additional_data: additional_data)
|
48
|
+
# end
|
49
|
+
# end
|
50
|
+
#
|
51
|
+
# # Decrypt auto-detects algorithm from data, so no change needed
|
52
|
+
# def decrypt_value(record, encrypted)
|
53
|
+
# context = build_context(record)
|
54
|
+
# additional_data = build_aad(record)
|
55
|
+
#
|
56
|
+
# Familia::Encryption.decrypt(encrypted,
|
57
|
+
# context: context,
|
58
|
+
# additional_data: additional_data)
|
59
|
+
# end
|
60
|
+
# end
|
61
|
+
# end
|
62
|
+
class << self
|
63
|
+
# Get or create a manager with specific algorithm
|
64
|
+
def manager(algorithm: nil)
|
65
|
+
@managers ||= {}
|
66
|
+
@managers[algorithm] ||= Manager.new(algorithm: algorithm)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Quick encryption with auto-selected best provider
|
70
|
+
def encrypt(plaintext, context:, additional_data: nil)
|
71
|
+
manager.encrypt(plaintext, context: context, additional_data: additional_data)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Quick decryption (auto-detects algorithm from data)
|
75
|
+
def decrypt(encrypted_json, context:, additional_data: nil)
|
76
|
+
manager.decrypt(encrypted_json, context: context, additional_data: additional_data)
|
77
|
+
end
|
78
|
+
|
79
|
+
# Encrypt with specific algorithm
|
80
|
+
def encrypt_with(algorithm, plaintext, context:, additional_data: nil)
|
81
|
+
manager(algorithm: algorithm).encrypt(
|
82
|
+
plaintext,
|
83
|
+
context: context,
|
84
|
+
additional_data: additional_data
|
85
|
+
)
|
86
|
+
end
|
87
|
+
|
88
|
+
# Derivation counter for monitoring no-caching behavior
|
89
|
+
def derivation_count
|
90
|
+
@derivation_count ||= Concurrent::AtomicFixnum.new(0)
|
91
|
+
end
|
92
|
+
|
93
|
+
def reset_derivation_count!
|
94
|
+
derivation_count.value = 0
|
95
|
+
end
|
96
|
+
|
97
|
+
# Clear key from memory (no security guarantees in Ruby)
|
98
|
+
def secure_wipe(key)
|
99
|
+
key&.clear
|
100
|
+
end
|
101
|
+
|
102
|
+
# Get info about current encryption setup
|
103
|
+
def status
|
104
|
+
Registry.setup! if Registry.providers.empty?
|
105
|
+
|
106
|
+
{
|
107
|
+
default_algorithm: Registry.default_provider&.algorithm,
|
108
|
+
available_algorithms: Registry.available_algorithms,
|
109
|
+
preferred_available: Registry.default_provider&.class&.name,
|
110
|
+
using_hardware: hardware_acceleration?,
|
111
|
+
key_versions: encryption_keys.keys,
|
112
|
+
current_version: current_key_version
|
113
|
+
}
|
114
|
+
end
|
115
|
+
|
116
|
+
# Check if we're using hardware acceleration
|
117
|
+
def hardware_acceleration?
|
118
|
+
provider = Registry.default_provider
|
119
|
+
provider && provider.class.name.include?('Hardware')
|
120
|
+
end
|
121
|
+
|
122
|
+
# Benchmark available providers
|
123
|
+
def benchmark(iterations: 1000)
|
124
|
+
require 'benchmark'
|
125
|
+
test_data = 'x' * 1024 # 1KB test
|
126
|
+
context = 'benchmark:test'
|
127
|
+
|
128
|
+
results = {}
|
129
|
+
Registry.providers.each do |algo, provider_class|
|
130
|
+
next unless provider_class.available?
|
131
|
+
|
132
|
+
mgr = Manager.new(algorithm: algo)
|
133
|
+
time = Benchmark.realtime do
|
134
|
+
iterations.times do
|
135
|
+
encrypted = mgr.encrypt(test_data, context: context)
|
136
|
+
mgr.decrypt(encrypted, context: context)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
results[algo] = {
|
141
|
+
time: time,
|
142
|
+
ops_per_sec: (iterations * 2 / time).round,
|
143
|
+
priority: provider_class.priority
|
144
|
+
}
|
145
|
+
end
|
146
|
+
|
147
|
+
results
|
148
|
+
end
|
149
|
+
|
150
|
+
def validate_configuration!
|
151
|
+
raise EncryptionError, 'No encryption keys configured' if encryption_keys.empty?
|
152
|
+
raise EncryptionError, 'No current key version set' unless current_key_version
|
153
|
+
|
154
|
+
current_key = encryption_keys[current_key_version]
|
155
|
+
raise EncryptionError, "Current key version not found: #{current_key_version}" unless current_key
|
156
|
+
|
157
|
+
begin
|
158
|
+
Base64.strict_decode64(current_key)
|
159
|
+
rescue ArgumentError
|
160
|
+
raise EncryptionError, 'Current encryption key is not valid Base64'
|
161
|
+
end
|
162
|
+
|
163
|
+
Registry.setup!
|
164
|
+
raise EncryptionError, 'No encryption providers available' unless Registry.default_provider
|
165
|
+
end
|
166
|
+
|
167
|
+
private
|
168
|
+
|
169
|
+
def encryption_keys
|
170
|
+
Familia.config.encryption_keys || {}
|
171
|
+
end
|
172
|
+
|
173
|
+
def current_key_version
|
174
|
+
Familia.config.current_key_version
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# lib/familia/encryption_request_cache.rb
|
2
|
+
#
|
3
|
+
# Request-scoped caching for encryption keys (if needed for performance)
|
4
|
+
# This should ONLY be enabled if performance testing shows it's necessary
|
5
|
+
#
|
6
|
+
# Usage in Rack middleware:
|
7
|
+
# class ClearEncryptionCacheMiddleware
|
8
|
+
# def call(env)
|
9
|
+
# Familia::Encryption.clear_request_cache!
|
10
|
+
# @app.call(env)
|
11
|
+
# ensure
|
12
|
+
# Familia::Encryption.clear_request_cache!
|
13
|
+
# end
|
14
|
+
# end
|
15
|
+
|
16
|
+
module Familia
|
17
|
+
module Encryption
|
18
|
+
class << self
|
19
|
+
# Enable request-scoped caching (opt-in for performance)
|
20
|
+
def with_request_cache
|
21
|
+
Thread.current[:familia_request_cache_enabled] = true
|
22
|
+
Thread.current[:familia_request_cache] = {}
|
23
|
+
yield
|
24
|
+
ensure
|
25
|
+
clear_request_cache!
|
26
|
+
end
|
27
|
+
|
28
|
+
# Clear all cached keys and disable caching
|
29
|
+
def clear_request_cache!
|
30
|
+
if (cache = Thread.current[:familia_request_cache])
|
31
|
+
cache.each_value { |key| secure_wipe(key) }
|
32
|
+
cache.clear
|
33
|
+
end
|
34
|
+
Thread.current[:familia_request_cache_enabled] = false
|
35
|
+
Thread.current[:familia_request_cache] = nil
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
# Modified derive_key that uses request cache when enabled
|
41
|
+
def derive_key_with_optional_cache(context, version: nil)
|
42
|
+
version ||= current_key_version
|
43
|
+
master_key = get_master_key(version)
|
44
|
+
|
45
|
+
# Only use cache if explicitly enabled for this request
|
46
|
+
if Thread.current[:familia_request_cache_enabled]
|
47
|
+
cache = Thread.current[:familia_request_cache] ||= {}
|
48
|
+
cache_key = "#{version}:#{context}"
|
49
|
+
|
50
|
+
# Return cached key if available (within same request only)
|
51
|
+
if (cached = cache[cache_key])
|
52
|
+
return cached.dup
|
53
|
+
end
|
54
|
+
|
55
|
+
# Derive and cache for this request only
|
56
|
+
derived = perform_key_derivation(master_key, context)
|
57
|
+
cache[cache_key] = derived.dup
|
58
|
+
derived
|
59
|
+
else
|
60
|
+
# Default: no caching for maximum security
|
61
|
+
perform_key_derivation(master_key, context)
|
62
|
+
end
|
63
|
+
ensure
|
64
|
+
secure_wipe(master_key) if master_key
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
# lib/familia/field_types/encrypted_field_type.rb
|
2
|
+
|
3
|
+
require_relative '../../field_type'
|
4
|
+
|
5
|
+
module Familia
|
6
|
+
class EncryptedFieldType < FieldType
|
7
|
+
attr_reader :aad_fields
|
8
|
+
|
9
|
+
def initialize(name, aad_fields: [], **options)
|
10
|
+
super(name, **options.merge(on_conflict: :raise))
|
11
|
+
@aad_fields = Array(aad_fields).freeze
|
12
|
+
end
|
13
|
+
|
14
|
+
def define_setter(klass)
|
15
|
+
field_name = @name
|
16
|
+
method_name = @method_name
|
17
|
+
field_type = self
|
18
|
+
|
19
|
+
handle_method_conflict(klass, :"#{method_name}=") do
|
20
|
+
klass.define_method :"#{method_name}=" do |value|
|
21
|
+
encrypted = value.nil? ? nil : field_type.encrypt_value(self, value)
|
22
|
+
instance_variable_set(:"@#{field_name}", encrypted)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def define_getter(klass)
|
28
|
+
field_name = @name
|
29
|
+
method_name = @method_name
|
30
|
+
field_type = self
|
31
|
+
|
32
|
+
handle_method_conflict(klass, method_name) do
|
33
|
+
klass.define_method method_name do
|
34
|
+
encrypted = instance_variable_get(:"@#{field_name}")
|
35
|
+
encrypted.nil? ? nil : field_type.decrypt_value(self, encrypted)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def define_fast_writer(klass)
|
41
|
+
# Encrypted fields override base fast writer for security
|
42
|
+
return unless @fast_method_name&.to_s&.end_with?('!')
|
43
|
+
|
44
|
+
field_name = @name
|
45
|
+
method_name = @method_name
|
46
|
+
fast_method_name = @fast_method_name
|
47
|
+
field_type = self
|
48
|
+
|
49
|
+
handle_method_conflict(klass, fast_method_name) do
|
50
|
+
klass.define_method fast_method_name do |val|
|
51
|
+
raise ArgumentError, "#{fast_method_name} requires a value" if val.nil?
|
52
|
+
|
53
|
+
encrypted = field_type.encrypt_value(self, val)
|
54
|
+
send(:"#{method_name}=", val) if method_name
|
55
|
+
|
56
|
+
ret = hset(field_name, encrypted)
|
57
|
+
ret.zero? || ret.positive?
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Encrypt a value for the given record
|
63
|
+
def encrypt_value(record, value)
|
64
|
+
context = build_context(record)
|
65
|
+
additional_data = build_aad(record)
|
66
|
+
|
67
|
+
Familia::Encryption.encrypt(value, context: context, additional_data: additional_data)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Decrypt a value for the given record
|
71
|
+
def decrypt_value(record, encrypted)
|
72
|
+
context = build_context(record)
|
73
|
+
additional_data = build_aad(record)
|
74
|
+
|
75
|
+
Familia::Encryption.decrypt(encrypted, context: context, additional_data: additional_data)
|
76
|
+
end
|
77
|
+
|
78
|
+
def persistent?
|
79
|
+
true
|
80
|
+
end
|
81
|
+
|
82
|
+
def category
|
83
|
+
:encrypted
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
# Build encryption context string
|
89
|
+
def build_context(record)
|
90
|
+
"#{record.class.name}:#{@name}:#{record.identifier}"
|
91
|
+
end
|
92
|
+
|
93
|
+
# Build Additional Authenticated Data (AAD) for authenticated encryption
|
94
|
+
#
|
95
|
+
# AAD provides cryptographic binding between encrypted field values and their
|
96
|
+
# containing record context. This prevents attackers from moving encrypted
|
97
|
+
# values between different records or field contexts, even with database access.
|
98
|
+
#
|
99
|
+
# ## Persistence-Dependent Behavior
|
100
|
+
#
|
101
|
+
# AAD is only generated for records that exist in the database (`record.exists?`).
|
102
|
+
# This creates an important behavioral distinction:
|
103
|
+
#
|
104
|
+
# **Before Save (record.exists? == false):**
|
105
|
+
# - AAD = nil
|
106
|
+
# - Encryption context = "ClassName:fieldname:identifier" only
|
107
|
+
# - Values can be encrypted/decrypted freely in memory
|
108
|
+
#
|
109
|
+
# **After Save (record.exists? == true):**
|
110
|
+
# - AAD = record.identifier (no aad_fields) or SHA256(identifier:field1:field2:...)
|
111
|
+
# - Full cryptographic binding to database state
|
112
|
+
# - Moving encrypted values between records/contexts will fail decryption
|
113
|
+
#
|
114
|
+
# ## Security Implications
|
115
|
+
#
|
116
|
+
# This design prevents several attack vectors:
|
117
|
+
#
|
118
|
+
# 1. **Field Value Swapping**: With aad_fields specified, encrypted values
|
119
|
+
# become bound to other field values. Changing owner_id breaks decryption.
|
120
|
+
#
|
121
|
+
# 2. **Cross-Record Migration**: Even without aad_fields, encrypted values
|
122
|
+
# are bound to their specific record identifier after persistence.
|
123
|
+
#
|
124
|
+
# 3. **Temporal Consistency**: Re-encrypting the same plaintext after
|
125
|
+
# field changes produces different ciphertext due to AAD changes.
|
126
|
+
#
|
127
|
+
# ## Usage Patterns
|
128
|
+
#
|
129
|
+
# ```ruby
|
130
|
+
# # No AAD fields - basic record binding
|
131
|
+
# encrypted_field :secret_value
|
132
|
+
#
|
133
|
+
# # With AAD fields - multi-field binding
|
134
|
+
# encrypted_field :content, aad_fields: [:owner_id, :doc_type]
|
135
|
+
# ```
|
136
|
+
#
|
137
|
+
# @param record [Familia::Horreum] The record instance containing this field
|
138
|
+
# @return [String, nil] AAD string for encryption, or nil for unsaved records
|
139
|
+
#
|
140
|
+
def build_aad(record)
|
141
|
+
return nil unless record.exists?
|
142
|
+
|
143
|
+
if @aad_fields.empty?
|
144
|
+
# When no AAD fields specified, just use identifier
|
145
|
+
record.identifier
|
146
|
+
else
|
147
|
+
# Include specified field values in AAD
|
148
|
+
values = @aad_fields.map { |field| record.send(field) }
|
149
|
+
Digest::SHA256.hexdigest([record.identifier, *values].compact.join(':'))
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# lib/familia/features/encrypted_fields.rb
|
2
|
+
|
3
|
+
require_relative 'encrypted_fields/encrypted_field_type'
|
4
|
+
|
5
|
+
module Familia
|
6
|
+
module Features
|
7
|
+
module EncryptedFields
|
8
|
+
def self.included(base)
|
9
|
+
base.extend ClassMethods
|
10
|
+
end
|
11
|
+
|
12
|
+
module ClassMethods
|
13
|
+
# Define an encrypted field
|
14
|
+
# @param name [Symbol] Field name
|
15
|
+
# @param aad_fields [Array<Symbol>] Optional fields to include in AAD
|
16
|
+
# @param kwargs [Hash] Additional field options
|
17
|
+
def encrypted_field(name, aad_fields: [], **)
|
18
|
+
require_relative 'encrypted_fields/encrypted_field_type'
|
19
|
+
|
20
|
+
field_type = EncryptedFieldType.new(name, aad_fields: aad_fields, **)
|
21
|
+
register_field_type(field_type)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
Familia::Base.add_feature self, :encrypted_fields
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -1,100 +1,130 @@
|
|
1
1
|
# lib/familia/features/expiration.rb
|
2
2
|
|
3
|
+
module Familia
|
4
|
+
module Features
|
5
|
+
# Famnilia::Features::Expiration
|
6
|
+
#
|
7
|
+
module Expiration
|
8
|
+
@default_expiration = nil
|
3
9
|
|
4
|
-
|
10
|
+
def self.included(base)
|
11
|
+
Familia.ld "[#{base}] Loaded #{self}"
|
12
|
+
base.extend ClassMethods
|
5
13
|
|
6
|
-
|
7
|
-
|
14
|
+
# Optionally define default_expiration in the class to make
|
15
|
+
# sure we always have an array to work with.
|
16
|
+
return if base.instance_variable_defined?(:@default_expiration)
|
8
17
|
|
9
|
-
|
18
|
+
base.instance_variable_set(:@default_expiration, @default_expiration) # set above
|
19
|
+
end
|
10
20
|
|
11
|
-
|
21
|
+
# ClassMethods
|
22
|
+
#
|
23
|
+
module ClassMethods
|
24
|
+
attr_writer :default_expiration
|
12
25
|
|
13
|
-
|
14
|
-
|
15
|
-
|
26
|
+
def default_expiration(num = nil)
|
27
|
+
@default_expiration = num.to_f unless num.nil?
|
28
|
+
@default_expiration || parent&.default_expiration || Familia.default_expiration
|
29
|
+
end
|
16
30
|
end
|
17
31
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
Familia.ld "[#{base}] Loaded #{self}"
|
22
|
-
base.extend ClassMethods
|
32
|
+
def default_expiration=(num)
|
33
|
+
@default_expiration = num.to_f
|
34
|
+
end
|
23
35
|
|
24
|
-
|
25
|
-
|
26
|
-
unless base.instance_variable_defined?(:@default_expiration)
|
27
|
-
base.instance_variable_set(:@default_expiration, @default_expiration) # set above
|
36
|
+
def default_expiration
|
37
|
+
@default_expiration || self.class.default_expiration
|
28
38
|
end
|
29
|
-
end
|
30
39
|
|
31
|
-
|
32
|
-
|
33
|
-
|
40
|
+
# Sets an expiration time for the Database data associated with this object.
|
41
|
+
#
|
42
|
+
# This method allows setting a Time To Live (TTL) for the data in Redis,
|
43
|
+
# after which it will be automatically removed.
|
44
|
+
#
|
45
|
+
# @param default_expiration [Integer, nil] The Time To Live in seconds. If nil, the default
|
46
|
+
# TTL will be used.
|
47
|
+
#
|
48
|
+
# @return [Boolean] Returns true if the expiration was set successfully,
|
49
|
+
# false otherwise.
|
50
|
+
#
|
51
|
+
# @example Setting an expiration of one day
|
52
|
+
# object.update_expiration(default_expiration: 86400)
|
53
|
+
#
|
54
|
+
# @note If Default expiration is set to zero, the expiration will be removed, making the
|
55
|
+
# data persist indefinitely.
|
56
|
+
#
|
57
|
+
# @raise [Familia::Problem] Raises an error if the default expiration is not a non-negative
|
58
|
+
# integer.
|
59
|
+
#
|
60
|
+
def update_expiration(default_expiration: nil)
|
61
|
+
default_expiration ||= self.default_expiration
|
62
|
+
|
63
|
+
if self.class.has_relations?
|
64
|
+
Familia.ld "[update_expiration] #{self.class} has relations: #{self.class.related_fields.keys}"
|
65
|
+
self.class.related_fields.each do |name, definition|
|
66
|
+
next if definition.opts[:default_expiration].nil?
|
67
|
+
|
68
|
+
obj = send(name)
|
69
|
+
Familia.ld "[update_expiration] Updating expiration for #{name} (#{obj.dbkey}) to #{default_expiration}"
|
70
|
+
obj.update_expiration(default_expiration: default_expiration)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# It's important to raise exceptions here and not just log warnings. We
|
75
|
+
# don't want to silently fail at setting expirations and cause data
|
76
|
+
# retention issues (e.g. not removed in a timely fashion).
|
77
|
+
#
|
78
|
+
# For the same reason, we don't want to default to 0 bc there's not a
|
79
|
+
# good reason for the default_expiration to not be set in the first place. If the
|
80
|
+
# class doesn't have a default_expiration, the default comes from
|
81
|
+
# Familia.default_expiration (which is 0, aka no-op/skip/do nothing).
|
82
|
+
unless default_expiration.is_a?(Numeric)
|
83
|
+
raise Familia::Problem, "Default expiration must be a number (#{default_expiration.class} in #{self.class})"
|
84
|
+
end
|
34
85
|
|
35
|
-
|
36
|
-
|
86
|
+
# If zero, simply skips setting an expiry for this key. If we were to set
|
87
|
+
# 0 the database would drop the key immediately.
|
88
|
+
return Familia.ld "[update_expiration] No expiration for #{self.class} (#{dbkey})" if default_expiration.zero?
|
89
|
+
|
90
|
+
Familia.ld "[update_expiration] Expires #{dbkey} in #{default_expiration} seconds"
|
91
|
+
|
92
|
+
# Redis' EXPIRE command returns 1 if the timeout was set, 0 if key does
|
93
|
+
# not exist or the timeout could not be set. Via redis-rb here, it's
|
94
|
+
# a bool.
|
95
|
+
expire(default_expiration)
|
96
|
+
end
|
97
|
+
|
98
|
+
Familia::Base.add_feature self, :expiration
|
37
99
|
end
|
100
|
+
end
|
101
|
+
end
|
38
102
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
#
|
103
|
+
module Familia
|
104
|
+
# Add a default update_expiration method for all classes that include
|
105
|
+
# Familia::Base. Since expiration is a core feature, we can confidently
|
106
|
+
# call `horreum_instance.update_expiration` without defensive programming
|
107
|
+
# even when expiration is not enabled for the horreum_instance class.
|
108
|
+
module Base
|
109
|
+
# Base implementation of update_expiration that maintains API compatibility
|
110
|
+
# with the :expiration feature's implementation.
|
46
111
|
#
|
47
|
-
#
|
48
|
-
#
|
112
|
+
# This is a no-op implementation that gets overridden by features like
|
113
|
+
# :expiration. It accepts an optional default_expiration parameter to maintain interface
|
114
|
+
# compatibility with the overriding implementations.
|
49
115
|
#
|
50
|
-
# @
|
51
|
-
#
|
116
|
+
# @param default_expiration [Integer, nil] Time To Live in seconds
|
117
|
+
# @return [nil] Always returns nil
|
52
118
|
#
|
53
|
-
# @note
|
54
|
-
#
|
55
|
-
#
|
56
|
-
# @raise [Familia::Problem] Raises an error if the default expiration is not a non-negative
|
57
|
-
# integer.
|
119
|
+
# @note This is a no-op implementation. Classes that need expiration
|
120
|
+
# functionality should include the :expiration feature.
|
58
121
|
#
|
59
122
|
def update_expiration(default_expiration: nil)
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
next if definition.opts[:default_expiration].nil?
|
66
|
-
obj = send(name)
|
67
|
-
Familia.ld "[update_expiration] Updating expiration for #{name} (#{obj.dbkey}) to #{default_expiration}"
|
68
|
-
obj.update_expiration(default_expiration: default_expiration)
|
69
|
-
end
|
70
|
-
end
|
71
|
-
|
72
|
-
# It's important to raise exceptions here and not just log warnings. We
|
73
|
-
# don't want to silently fail at setting expirations and cause data
|
74
|
-
# retention issues (e.g. not removed in a timely fashion).
|
75
|
-
#
|
76
|
-
# For the same reason, we don't want to default to 0 bc there's not a
|
77
|
-
# good reason for the default_expiration to not be set in the first place. If the
|
78
|
-
# class doesn't have a default_expiration, the default comes from Familia.default_expiration (which
|
79
|
-
# is 0).
|
80
|
-
unless default_expiration.is_a?(Numeric)
|
81
|
-
raise Familia::Problem, "Default expiration must be a number (#{default_expiration.class} in #{self.class})"
|
82
|
-
end
|
83
|
-
|
84
|
-
if default_expiration.zero?
|
85
|
-
return Familia.ld "[update_expiration] No expiration for #{self.class} (#{dbkey})"
|
86
|
-
end
|
87
|
-
|
88
|
-
Familia.ld "[update_expiration] Expires #{dbkey} in #{default_expiration} seconds"
|
89
|
-
|
90
|
-
# Redis' EXPIRE command returns 1 if the timeout was set, 0 if key does
|
91
|
-
# not exist or the timeout could not be set. Via redis-rb here, it's
|
92
|
-
# a bool.
|
93
|
-
expire(default_expiration)
|
123
|
+
Familia.ld <<~LOG
|
124
|
+
[update_expiration] Feature not enabled for #{self.class}.
|
125
|
+
Key: #{dbkey} Arg: #{default_expiration} (caller: #{caller(1..1)})
|
126
|
+
LOG
|
127
|
+
nil
|
94
128
|
end
|
95
|
-
extend ClassMethods
|
96
|
-
|
97
|
-
Familia::Base.add_feature self, :expiration
|
98
129
|
end
|
99
|
-
|
100
130
|
end
|