familia 2.0.0.pre4 → 2.0.0.pre6
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 +11 -8
- data/Gemfile +5 -1
- data/Gemfile.lock +19 -3
- data/README.md +36 -157
- data/docs/overview.md +359 -0
- data/docs/wiki/API-Reference.md +347 -0
- data/docs/wiki/Connection-Pooling-Guide.md +437 -0
- data/docs/wiki/Encrypted-Fields-Overview.md +101 -0
- data/docs/wiki/Expiration-Feature-Guide.md +596 -0
- data/docs/wiki/Feature-System-Guide.md +600 -0
- data/docs/wiki/Features-System-Developer-Guide.md +892 -0
- data/docs/wiki/Field-System-Guide.md +784 -0
- data/docs/wiki/Home.md +106 -0
- data/docs/wiki/Implementation-Guide.md +276 -0
- data/docs/wiki/Quantization-Feature-Guide.md +721 -0
- data/docs/wiki/RelatableObjects-Guide.md +563 -0
- data/docs/wiki/Security-Model.md +183 -0
- data/docs/wiki/Transient-Fields-Guide.md +280 -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/data_type/types/counter.rb +38 -0
- data/lib/familia/{datatype → data_type}/types/hashkey.rb +20 -2
- data/lib/familia/{datatype → data_type}/types/list.rb +17 -18
- data/lib/familia/data_type/types/lock.rb +43 -0
- data/lib/familia/{datatype → data_type}/types/sorted_set.rb +17 -17
- data/lib/familia/{datatype → data_type}/types/string.rb +11 -3
- data/lib/familia/{datatype → data_type}/types/unsorted_set.rb +17 -18
- data/lib/familia/{datatype.rb → data_type.rb} +12 -14
- data/lib/familia/encryption/encrypted_data.rb +137 -0
- data/lib/familia/encryption/manager.rb +119 -0
- data/lib/familia/encryption/provider.rb +49 -0
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +123 -0
- data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +184 -0
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +138 -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/errors.rb +17 -3
- data/lib/familia/features/encrypted_fields/concealed_string.rb +295 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +221 -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 +273 -0
- data/lib/familia/horreum/{connection.rb → core/connection.rb} +6 -15
- data/lib/familia/horreum/{commands.rb → core/database_commands.rb} +20 -21
- data/lib/familia/horreum/core/serialization.rb +535 -0
- data/lib/familia/horreum/{utils.rb → core/utils.rb} +9 -12
- data/lib/familia/horreum/core.rb +21 -0
- data/lib/familia/horreum/{settings.rb → shared/settings.rb} +10 -4
- data/lib/familia/horreum/subclass/definition.rb +469 -0
- data/lib/familia/horreum/{class_methods.rb → subclass/management.rb} +27 -250
- data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
- data/lib/familia/horreum.rb +30 -22
- 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 +3 -2
- data/try/core/base_enhancements_try.rb +115 -0
- data/try/core/connection_try.rb +0 -1
- data/try/core/create_method_try.rb +240 -0
- data/try/core/database_consistency_try.rb +299 -0
- data/try/core/errors_try.rb +25 -5
- data/try/core/familia_extended_try.rb +3 -4
- data/try/core/familia_try.rb +1 -2
- data/try/core/persistence_operations_try.rb +297 -0
- 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/data_types/counter_try.rb +93 -0
- 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/data_types/lock_try.rb +133 -0
- 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/debug_aad_process.rb +82 -0
- data/try/debugging/debug_concealed_internal.rb +59 -0
- data/try/debugging/debug_concealed_reveal.rb +61 -0
- data/try/debugging/debug_context_aad.rb +68 -0
- data/try/debugging/debug_context_simple.rb +80 -0
- data/try/debugging/debug_cross_context.rb +62 -0
- data/try/debugging/debug_database_load.rb +64 -0
- data/try/debugging/debug_encrypted_json_check.rb +53 -0
- data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
- data/try/debugging/debug_exists_lifecycle.rb +54 -0
- data/try/debugging/debug_field_decrypt.rb +74 -0
- data/try/debugging/debug_fresh_cross_context.rb +73 -0
- data/try/debugging/debug_load_path.rb +66 -0
- data/try/debugging/debug_method_definition.rb +46 -0
- data/try/debugging/debug_method_resolution.rb +41 -0
- data/try/debugging/debug_minimal.rb +24 -0
- data/try/debugging/debug_provider.rb +68 -0
- data/try/debugging/debug_secure_behavior.rb +73 -0
- data/try/debugging/debug_string_class.rb +46 -0
- data/try/debugging/debug_test.rb +46 -0
- data/try/debugging/debug_test_design.rb +80 -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 +125 -0
- data/try/features/encrypted_fields_integration_try.rb +216 -0
- data/try/features/encrypted_fields_no_cache_security_try.rb +219 -0
- data/try/features/encrypted_fields_security_try.rb +377 -0
- data/try/features/encryption_fields/aad_protection_try.rb +138 -0
- data/try/features/encryption_fields/concealed_string_core_try.rb +250 -0
- data/try/features/encryption_fields/context_isolation_try.rb +141 -0
- data/try/features/encryption_fields/error_conditions_try.rb +116 -0
- data/try/features/encryption_fields/fresh_key_derivation_try.rb +128 -0
- data/try/features/encryption_fields/fresh_key_try.rb +168 -0
- data/try/features/encryption_fields/key_rotation_try.rb +123 -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 +56 -0
- data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
- data/try/features/encryption_fields/thread_safety_try.rb +199 -0
- data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -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 +67 -0
- data/try/horreum/base_try.rb +157 -3
- 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 +1 -2
- data/try/horreum/relations_try.rb +1 -2
- data/try/horreum/serialization_persistent_fields_try.rb +165 -0
- data/try/horreum/serialization_try.rb +41 -7
- 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 +1 -2
- data/try/models/customer_try.rb +1 -2
- data/try/models/datatype_base_try.rb +1 -2
- data/try/models/familia_object_try.rb +0 -1
- metadata +131 -23
- data/lib/familia/horreum/serialization.rb +0 -445
@@ -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
|
data/lib/familia/errors.rb
CHANGED
@@ -34,8 +34,8 @@ module Familia
|
|
34
34
|
# Set Familia.connection_provider or use middleware to provide connections.
|
35
35
|
class NoConnectionAvailable < Problem; end
|
36
36
|
|
37
|
-
# Raised when attempting to refresh an object whose key doesn't exist in
|
38
|
-
class KeyNotFoundError <
|
37
|
+
# Raised when attempting to refresh an object whose key doesn't exist in the database
|
38
|
+
class KeyNotFoundError < NonUniqueKey
|
39
39
|
attr_reader :key
|
40
40
|
|
41
41
|
def initialize(key)
|
@@ -44,7 +44,21 @@ module Familia
|
|
44
44
|
end
|
45
45
|
|
46
46
|
def message
|
47
|
-
"Key not found
|
47
|
+
"Key not found: #{key}"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Raised when attempting to create an object that already exists in the database
|
52
|
+
class RecordExistsError < NonUniqueKey
|
53
|
+
attr_reader :key
|
54
|
+
|
55
|
+
def initialize(key)
|
56
|
+
@key = key
|
57
|
+
super
|
58
|
+
end
|
59
|
+
|
60
|
+
def message
|
61
|
+
"Key already exists: #{key}"
|
48
62
|
end
|
49
63
|
end
|
50
64
|
end
|
@@ -0,0 +1,295 @@
|
|
1
|
+
# lib/familia/features/encrypted_fields/concealed_string.rb
|
2
|
+
|
3
|
+
# ConcealedString
|
4
|
+
#
|
5
|
+
# A secure wrapper for encrypted field values that prevents accidental
|
6
|
+
# plaintext leakage through serialization, logging, or debugging.
|
7
|
+
#
|
8
|
+
# Unlike RedactedString (which wraps plaintext), ConcealedString wraps
|
9
|
+
# encrypted data and provides controlled decryption through the .reveal API.
|
10
|
+
#
|
11
|
+
# Security Model:
|
12
|
+
# - Contains encrypted JSON data, never plaintext
|
13
|
+
# - Requires explicit .reveal { } for decryption and plaintext access
|
14
|
+
# - ALL serialization methods return '[CONCEALED]' to prevent leakage
|
15
|
+
# - Maintains encryption context for proper AAD handling
|
16
|
+
# - Thread-safe and supports concurrent access
|
17
|
+
#
|
18
|
+
# Key Security Features:
|
19
|
+
# 1. Universal Serialization Safety - ALL to_* methods protected
|
20
|
+
# 2. Debugging Safety - inspect, logging, console output shows [CONCEALED]
|
21
|
+
# 3. Exception Safety - never leaks plaintext in error messages
|
22
|
+
# 4. Future-proof - any new serialization method automatically safe
|
23
|
+
# 5. Memory Clearing - best-effort encrypted data clearing
|
24
|
+
#
|
25
|
+
# Critical Design Principles:
|
26
|
+
# - Secure by default - no auto-decryption anywhere
|
27
|
+
# - Explicit decryption - .reveal required for plaintext access
|
28
|
+
# - Comprehensive protection - covers ALL serialization paths
|
29
|
+
# - Auditable access - easy to grep for .reveal usage
|
30
|
+
#
|
31
|
+
# Example Usage:
|
32
|
+
# user = User.new
|
33
|
+
# user.secret_data = "sensitive info" # Encrypts and wraps
|
34
|
+
# user.secret_data # Returns ConcealedString
|
35
|
+
# user.secret_data.reveal { |plain| ... } # Explicit decryption
|
36
|
+
# user.to_h # Safe - contains [CONCEALED]
|
37
|
+
# user.to_json # Safe - contains [CONCEALED]
|
38
|
+
#
|
39
|
+
class ConcealedString
|
40
|
+
# Create a concealed string wrapper
|
41
|
+
#
|
42
|
+
# @param encrypted_data [String] The encrypted JSON data
|
43
|
+
# @param record [Familia::Horreum] The record instance for context
|
44
|
+
# @param field_type [EncryptedFieldType] The field type for decryption
|
45
|
+
#
|
46
|
+
def initialize(encrypted_data, record, field_type)
|
47
|
+
@encrypted_data = encrypted_data.freeze
|
48
|
+
@record = record
|
49
|
+
@field_type = field_type
|
50
|
+
@cleared = false
|
51
|
+
|
52
|
+
# Parse and validate the encrypted data structure
|
53
|
+
if @encrypted_data
|
54
|
+
begin
|
55
|
+
@encrypted_data_obj = Familia::Encryption::EncryptedData.from_json(@encrypted_data)
|
56
|
+
# Validate that the encrypted data is decryptable (algorithm supported, etc.)
|
57
|
+
@encrypted_data_obj.validate_decryptable!
|
58
|
+
rescue Familia::EncryptionError => e
|
59
|
+
raise Familia::EncryptionError, e.message
|
60
|
+
rescue StandardError => e
|
61
|
+
raise Familia::EncryptionError, "Invalid encrypted data: #{e.message}"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
ObjectSpace.define_finalizer(self, self.class.finalizer_proc(@encrypted_data))
|
66
|
+
end
|
67
|
+
|
68
|
+
# Primary API: reveal the decrypted plaintext in a controlled block
|
69
|
+
#
|
70
|
+
# This is the ONLY way to access plaintext from encrypted fields.
|
71
|
+
# The plaintext is decrypted fresh each time using the current
|
72
|
+
# record state and AAD context.
|
73
|
+
#
|
74
|
+
# Security Warning: Avoid operations inside the block that create
|
75
|
+
# uncontrolled copies of the plaintext (dup, interpolation, etc.)
|
76
|
+
#
|
77
|
+
# @yield [String] The decrypted plaintext value
|
78
|
+
# @return [Object] The return value of the block
|
79
|
+
#
|
80
|
+
# Example:
|
81
|
+
# user.api_token.reveal do |token|
|
82
|
+
# HTTP.post('/api', headers: { 'X-Token' => token })
|
83
|
+
# end
|
84
|
+
#
|
85
|
+
def reveal
|
86
|
+
raise ArgumentError, 'Block required for reveal' unless block_given?
|
87
|
+
raise SecurityError, 'Encrypted data already cleared' if cleared?
|
88
|
+
raise SecurityError, 'No encrypted data to reveal' if @encrypted_data.nil?
|
89
|
+
|
90
|
+
# Decrypt using current record context and AAD
|
91
|
+
plaintext = @field_type.decrypt_value(@record, @encrypted_data)
|
92
|
+
yield plaintext
|
93
|
+
end
|
94
|
+
|
95
|
+
# Validate that this ConcealedString belongs to the given record context
|
96
|
+
#
|
97
|
+
# This prevents cross-context attacks where encrypted data is moved between
|
98
|
+
# different records or field contexts. While moving ConcealedString objects
|
99
|
+
# manually is not a normal use case, this provides defense in depth.
|
100
|
+
#
|
101
|
+
# @param expected_record [Familia::Horreum] The record that should own this data
|
102
|
+
# @param expected_field_name [Symbol] The field name that should own this data
|
103
|
+
# @return [Boolean] true if contexts match, false otherwise
|
104
|
+
#
|
105
|
+
def belongs_to_context?(expected_record, expected_field_name)
|
106
|
+
return false if @record.nil? || @field_type.nil?
|
107
|
+
|
108
|
+
@record.class.name == expected_record.class.name &&
|
109
|
+
@record.identifier == expected_record.identifier &&
|
110
|
+
@field_type.instance_variable_get(:@name) == expected_field_name
|
111
|
+
end
|
112
|
+
|
113
|
+
# Clear the encrypted data from memory
|
114
|
+
#
|
115
|
+
# Safe to call multiple times. This provides best-effort memory
|
116
|
+
# clearing within Ruby's limitations.
|
117
|
+
#
|
118
|
+
def clear!
|
119
|
+
return if @cleared
|
120
|
+
|
121
|
+
@encrypted_data = nil
|
122
|
+
@record = nil
|
123
|
+
@field_type = nil
|
124
|
+
@cleared = true
|
125
|
+
freeze
|
126
|
+
end
|
127
|
+
|
128
|
+
# Check if the encrypted data has been cleared
|
129
|
+
#
|
130
|
+
# @return [Boolean] true if cleared, false otherwise
|
131
|
+
#
|
132
|
+
def cleared?
|
133
|
+
@cleared
|
134
|
+
end
|
135
|
+
|
136
|
+
def empty?
|
137
|
+
@encrypted_data.to_s.empty?
|
138
|
+
end
|
139
|
+
|
140
|
+
# Returns true when it's literally the same object, otherwise false.
|
141
|
+
# This prevents timing attacks where an attacker could potentially
|
142
|
+
# infer information about the secret value through comparison timing
|
143
|
+
def ==(other)
|
144
|
+
object_id.equal?(other.object_id) # same object
|
145
|
+
end
|
146
|
+
alias eql? ==
|
147
|
+
|
148
|
+
# Access the encrypted data for database storage
|
149
|
+
#
|
150
|
+
# This method is used internally by the field type system
|
151
|
+
# for persisting the encrypted data to the database.
|
152
|
+
#
|
153
|
+
# @return [String, nil] The encrypted JSON data
|
154
|
+
#
|
155
|
+
def encrypted_value
|
156
|
+
@encrypted_data
|
157
|
+
end
|
158
|
+
|
159
|
+
# Prevent accidental exposure through string conversion and serialization
|
160
|
+
#
|
161
|
+
# Ruby has two string conversion methods with different purposes:
|
162
|
+
# - to_s: explicit conversion (obj.to_s, string interpolation "#{obj}")
|
163
|
+
# - to_str: implicit coercion (File.read(obj), "prefix" + obj)
|
164
|
+
#
|
165
|
+
# We implement to_s for safe logging/debugging but deliberately omit to_str
|
166
|
+
# to prevent encrypted data from being used where strings are expected.
|
167
|
+
#
|
168
|
+
def to_s
|
169
|
+
'[CONCEALED]'
|
170
|
+
end
|
171
|
+
|
172
|
+
|
173
|
+
# String methods that should return safe concealed values
|
174
|
+
def upcase
|
175
|
+
'[CONCEALED]'
|
176
|
+
end
|
177
|
+
|
178
|
+
def downcase
|
179
|
+
'[CONCEALED]'
|
180
|
+
end
|
181
|
+
|
182
|
+
def length
|
183
|
+
11 # Fixed concealed length to match '[CONCEALED]' length
|
184
|
+
end
|
185
|
+
|
186
|
+
def size
|
187
|
+
length
|
188
|
+
end
|
189
|
+
|
190
|
+
def present?
|
191
|
+
true # Always return true since encrypted data exists
|
192
|
+
end
|
193
|
+
|
194
|
+
def blank?
|
195
|
+
false # Never blank if encrypted data exists
|
196
|
+
end
|
197
|
+
|
198
|
+
# String concatenation operations return concealed result
|
199
|
+
def +(other)
|
200
|
+
'[CONCEALED]'
|
201
|
+
end
|
202
|
+
|
203
|
+
def concat(other)
|
204
|
+
'[CONCEALED]'
|
205
|
+
end
|
206
|
+
|
207
|
+
# Handle coercion for concatenation like "string" + concealed
|
208
|
+
def coerce(other)
|
209
|
+
if other.is_a?(String)
|
210
|
+
['[CONCEALED]', '[CONCEALED]']
|
211
|
+
else
|
212
|
+
[other, '[CONCEALED]']
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
# String pattern matching methods
|
217
|
+
def strip
|
218
|
+
'[CONCEALED]'
|
219
|
+
end
|
220
|
+
|
221
|
+
def gsub(*args)
|
222
|
+
'[CONCEALED]'
|
223
|
+
end
|
224
|
+
|
225
|
+
def include?(substring)
|
226
|
+
false # Never reveal substring presence
|
227
|
+
end
|
228
|
+
|
229
|
+
# Enumerable methods for safety
|
230
|
+
def map
|
231
|
+
yield '[CONCEALED]' if block_given?
|
232
|
+
['[CONCEALED]']
|
233
|
+
end
|
234
|
+
|
235
|
+
def each
|
236
|
+
yield '[CONCEALED]' if block_given?
|
237
|
+
self
|
238
|
+
end
|
239
|
+
|
240
|
+
# Safe representation for debugging and console output
|
241
|
+
def inspect
|
242
|
+
'[CONCEALED]'
|
243
|
+
end
|
244
|
+
|
245
|
+
# Hash/Array serialization safety
|
246
|
+
def to_h
|
247
|
+
'[CONCEALED]'
|
248
|
+
end
|
249
|
+
|
250
|
+
def to_a
|
251
|
+
['[CONCEALED]']
|
252
|
+
end
|
253
|
+
|
254
|
+
# Consistent hash to prevent timing attacks
|
255
|
+
def hash
|
256
|
+
ConcealedString.hash
|
257
|
+
end
|
258
|
+
|
259
|
+
# Pattern matching safety (Ruby 3.0+)
|
260
|
+
def deconstruct
|
261
|
+
['[CONCEALED]']
|
262
|
+
end
|
263
|
+
|
264
|
+
def deconstruct_keys(keys)
|
265
|
+
{ concealed: true }
|
266
|
+
end
|
267
|
+
|
268
|
+
# Prevent exposure in JSON serialization
|
269
|
+
def to_json(*args)
|
270
|
+
'"[CONCEALED]"'
|
271
|
+
end
|
272
|
+
|
273
|
+
# Prevent exposure in Rails serialization (as_json -> to_json)
|
274
|
+
def as_json(*args)
|
275
|
+
'[CONCEALED]'
|
276
|
+
end
|
277
|
+
|
278
|
+
private
|
279
|
+
|
280
|
+
# Check if a string looks like encrypted JSON data
|
281
|
+
def encrypted_json?(data)
|
282
|
+
Familia::Encryption::EncryptedData.valid?(data)
|
283
|
+
end
|
284
|
+
|
285
|
+
# Finalizer to attempt memory cleanup
|
286
|
+
def self.finalizer_proc(encrypted_data)
|
287
|
+
proc do |id|
|
288
|
+
# Best effort cleanup - Ruby doesn't guarantee memory security
|
289
|
+
# Only clear if not frozen to avoid FrozenError
|
290
|
+
if encrypted_data&.respond_to?(:clear) && !encrypted_data.frozen?
|
291
|
+
encrypted_data.clear
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|
@@ -0,0 +1,221 @@
|
|
1
|
+
# lib/familia/field_types/encrypted_field_type.rb
|
2
|
+
|
3
|
+
require_relative '../../field_type'
|
4
|
+
require_relative 'concealed_string'
|
5
|
+
|
6
|
+
module Familia
|
7
|
+
class EncryptedFieldType < FieldType
|
8
|
+
attr_reader :aad_fields
|
9
|
+
|
10
|
+
def initialize(name, aad_fields: [], **options)
|
11
|
+
# Encrypted fields are not loggable by default for security
|
12
|
+
super(name, **options.merge(on_conflict: :raise, loggable: false))
|
13
|
+
@aad_fields = Array(aad_fields).freeze
|
14
|
+
end
|
15
|
+
|
16
|
+
def define_setter(klass)
|
17
|
+
field_name = @name
|
18
|
+
method_name = @method_name
|
19
|
+
field_type = self
|
20
|
+
|
21
|
+
handle_method_conflict(klass, :"#{method_name}=") do
|
22
|
+
klass.define_method :"#{method_name}=" do |value|
|
23
|
+
if value.nil?
|
24
|
+
instance_variable_set(:"@#{field_name}", nil)
|
25
|
+
elsif value.is_a?(::String) && value.empty?
|
26
|
+
# Handle empty strings - treat as nil for encrypted fields
|
27
|
+
instance_variable_set(:"@#{field_name}", nil)
|
28
|
+
elsif value.is_a?(ConcealedString)
|
29
|
+
# Already concealed, store as-is
|
30
|
+
instance_variable_set(:"@#{field_name}", value)
|
31
|
+
elsif field_type.encrypted_json?(value)
|
32
|
+
# Already encrypted JSON from database - wrap in ConcealedString without re-encrypting
|
33
|
+
concealed = ConcealedString.new(value, self, field_type)
|
34
|
+
instance_variable_set(:"@#{field_name}", concealed)
|
35
|
+
else
|
36
|
+
# Encrypt plaintext and wrap in ConcealedString
|
37
|
+
encrypted = field_type.encrypt_value(self, value)
|
38
|
+
concealed = ConcealedString.new(encrypted, self, field_type)
|
39
|
+
instance_variable_set(:"@#{field_name}", concealed)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def define_getter(klass)
|
46
|
+
field_name = @name
|
47
|
+
method_name = @method_name
|
48
|
+
field_type = self
|
49
|
+
|
50
|
+
handle_method_conflict(klass, method_name) do
|
51
|
+
klass.define_method method_name do
|
52
|
+
# Return ConcealedString directly - no auto-decryption!
|
53
|
+
# Caller must use .reveal { } for plaintext access
|
54
|
+
concealed = instance_variable_get(:"@#{field_name}")
|
55
|
+
|
56
|
+
# Return nil directly if that's what was set
|
57
|
+
return nil if concealed.nil?
|
58
|
+
|
59
|
+
# If we have a raw string (from direct instance variable manipulation),
|
60
|
+
# wrap it in ConcealedString which will trigger validation
|
61
|
+
if concealed.kind_of?(::String) && !concealed.is_a?(ConcealedString)
|
62
|
+
# This happens when someone directly sets the instance variable
|
63
|
+
# (e.g., during tampering tests). Wrapping in ConcealedString
|
64
|
+
# will trigger validate_decryptable! and catch invalid algorithms
|
65
|
+
begin
|
66
|
+
concealed = ConcealedString.new(concealed, self, field_type)
|
67
|
+
instance_variable_set(:"@#{field_name}", concealed)
|
68
|
+
rescue Familia::EncryptionError => e
|
69
|
+
# Increment derivation counter for failed validation attempts (similar to decrypt failures)
|
70
|
+
Familia::Encryption.derivation_count.increment
|
71
|
+
raise e
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Context validation: detect cross-context attacks
|
76
|
+
# Only validate if we have a proper ConcealedString instance
|
77
|
+
if concealed.is_a?(ConcealedString) && !concealed.belongs_to_context?(self, field_name)
|
78
|
+
raise Familia::EncryptionError, "Context isolation violation: encrypted field '#{field_name}' does not belong to #{self.class.name}:#{self.identifier}"
|
79
|
+
end
|
80
|
+
|
81
|
+
concealed
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def define_fast_writer(klass)
|
87
|
+
# Encrypted fields override base fast writer for security
|
88
|
+
return unless @fast_method_name&.to_s&.end_with?('!')
|
89
|
+
|
90
|
+
field_name = @name
|
91
|
+
method_name = @method_name
|
92
|
+
fast_method_name = @fast_method_name
|
93
|
+
field_type = self
|
94
|
+
|
95
|
+
handle_method_conflict(klass, fast_method_name) do
|
96
|
+
klass.define_method fast_method_name do |val|
|
97
|
+
raise ArgumentError, "#{fast_method_name} requires a value" if val.nil?
|
98
|
+
|
99
|
+
# Set via the setter method to get proper ConcealedString wrapping
|
100
|
+
send(:"#{method_name}=", val) if method_name
|
101
|
+
|
102
|
+
# Get the ConcealedString and extract encrypted data for storage
|
103
|
+
concealed = instance_variable_get(:"@#{field_name}")
|
104
|
+
encrypted_data = concealed&.encrypted_value
|
105
|
+
|
106
|
+
return false if encrypted_data.nil?
|
107
|
+
|
108
|
+
ret = hset(field_name, encrypted_data)
|
109
|
+
ret.zero? || ret.positive?
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Encrypt a value for the given record
|
115
|
+
def encrypt_value(record, value)
|
116
|
+
context = build_context(record)
|
117
|
+
additional_data = build_aad(record)
|
118
|
+
|
119
|
+
Familia::Encryption.encrypt(value, context: context, additional_data: additional_data)
|
120
|
+
end
|
121
|
+
|
122
|
+
# Decrypt a value for the given record
|
123
|
+
def decrypt_value(record, encrypted)
|
124
|
+
context = build_context(record)
|
125
|
+
additional_data = build_aad(record)
|
126
|
+
|
127
|
+
Familia::Encryption.decrypt(encrypted, context: context, additional_data: additional_data)
|
128
|
+
end
|
129
|
+
|
130
|
+
def persistent?
|
131
|
+
true
|
132
|
+
end
|
133
|
+
|
134
|
+
def category
|
135
|
+
:encrypted
|
136
|
+
end
|
137
|
+
|
138
|
+
# Check if a string looks like encrypted JSON data
|
139
|
+
def encrypted_json?(data)
|
140
|
+
Familia::Encryption::EncryptedData.valid?(data)
|
141
|
+
end
|
142
|
+
|
143
|
+
private
|
144
|
+
|
145
|
+
# Build encryption context string
|
146
|
+
def build_context(record)
|
147
|
+
"#{record.class.name}:#{@name}:#{record.identifier}"
|
148
|
+
end
|
149
|
+
|
150
|
+
# Build Additional Authenticated Data (AAD) for authenticated encryption
|
151
|
+
#
|
152
|
+
# AAD provides cryptographic binding between encrypted field values and their
|
153
|
+
# containing record context. This prevents attackers from moving encrypted
|
154
|
+
# values between different records or field contexts, even with database access.
|
155
|
+
#
|
156
|
+
# ## Consistent AAD Behavior
|
157
|
+
#
|
158
|
+
# AAD is now consistently generated based on the record's identifier, regardless
|
159
|
+
# of persistence state. This ensures that encrypted values remain decryptable
|
160
|
+
# after save/load cycles while still providing security benefits.
|
161
|
+
#
|
162
|
+
# **All Records (both new and persisted):**
|
163
|
+
# - AAD = record.identifier (no aad_fields) or SHA256(identifier:field1:field2:...)
|
164
|
+
# - Consistent cryptographic binding to record identity
|
165
|
+
# - Moving encrypted values between records/contexts will fail decryption
|
166
|
+
#
|
167
|
+
# ## Security Implications
|
168
|
+
#
|
169
|
+
# This design prevents several attack vectors:
|
170
|
+
#
|
171
|
+
# 1. **Field Value Swapping**: With aad_fields specified, encrypted values
|
172
|
+
# become bound to other field values. Changing owner_id breaks decryption.
|
173
|
+
#
|
174
|
+
# 2. **Cross-Record Migration**: Encrypted values are bound to their specific
|
175
|
+
# record identifier, preventing cross-record value movement.
|
176
|
+
#
|
177
|
+
# 3. **Temporal Consistency**: Re-encrypting the same plaintext after
|
178
|
+
# field changes produces different ciphertext due to AAD changes.
|
179
|
+
#
|
180
|
+
# ## Usage Patterns
|
181
|
+
#
|
182
|
+
# ```ruby
|
183
|
+
# # No AAD fields - basic record binding
|
184
|
+
# encrypted_field :secret_value
|
185
|
+
#
|
186
|
+
# # With AAD fields - multi-field binding
|
187
|
+
# encrypted_field :content, aad_fields: [:owner_id, :doc_type]
|
188
|
+
# ```
|
189
|
+
#
|
190
|
+
# @param record [Familia::Horreum] The record instance containing this field
|
191
|
+
# @return [String, nil] AAD string for encryption, or nil if no identifier
|
192
|
+
#
|
193
|
+
def build_aad(record)
|
194
|
+
# AAD provides consistent context-aware binding, regardless of persistence state
|
195
|
+
# This ensures save/load cycles work while maintaining context isolation
|
196
|
+
identifier = record.identifier
|
197
|
+
return nil if identifier.nil? || identifier.to_s.empty?
|
198
|
+
|
199
|
+
# Include class and field name in AAD for context isolation
|
200
|
+
# This prevents cross-class and cross-field value migration
|
201
|
+
base_components = [record.class.name, @name, identifier]
|
202
|
+
|
203
|
+
if @aad_fields.empty?
|
204
|
+
# When no AAD fields specified, use class:field:identifier
|
205
|
+
base_components.join(':')
|
206
|
+
else
|
207
|
+
# For unsaved records, don't enforce AAD fields since they can change
|
208
|
+
# For saved records, include field values for tamper protection
|
209
|
+
if record.exists?
|
210
|
+
# Include specified field values in AAD for persisted records
|
211
|
+
values = @aad_fields.map { |field| record.send(field) }
|
212
|
+
all_components = [*base_components, *values].compact
|
213
|
+
Digest::SHA256.hexdigest(all_components.join(':'))
|
214
|
+
else
|
215
|
+
# For unsaved records, only use class:field:identifier for context isolation
|
216
|
+
base_components.join(':')
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
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
|