familia 2.0.0.pre5 → 2.0.0.pre7
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/.github/workflows/claude-code-review.yml +57 -0
- data/.github/workflows/claude.yml +71 -0
- data/.gitignore +5 -1
- data/.rubocop.yml +3 -0
- data/CLAUDE.md +32 -10
- data/Gemfile +2 -2
- data/Gemfile.lock +4 -3
- data/docs/wiki/API-Reference.md +95 -18
- data/docs/wiki/Connection-Pooling-Guide.md +437 -0
- data/docs/wiki/Encrypted-Fields-Overview.md +40 -3
- data/docs/wiki/Expiration-Feature-Guide.md +596 -0
- data/docs/wiki/Feature-System-Guide.md +631 -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 +82 -15
- data/docs/wiki/Implementation-Guide.md +126 -33
- data/docs/wiki/Quantization-Feature-Guide.md +721 -0
- data/docs/wiki/Relationships-Guide.md +684 -0
- data/docs/wiki/Security-Model.md +65 -25
- data/docs/wiki/Transient-Fields-Guide.md +280 -0
- data/examples/bit_encoding_integration.rb +237 -0
- data/examples/redis_command_validation_example.rb +231 -0
- data/examples/relationships_basic.rb +273 -0
- data/lib/familia/base.rb +1 -1
- data/lib/familia/connection.rb +3 -3
- data/lib/familia/data_type/types/counter.rb +38 -0
- data/lib/familia/data_type/types/hashkey.rb +18 -0
- data/lib/familia/data_type/types/lock.rb +43 -0
- data/lib/familia/data_type/types/string.rb +9 -2
- data/lib/familia/data_type.rb +9 -6
- data/lib/familia/encryption/encrypted_data.rb +137 -0
- data/lib/familia/encryption/manager.rb +21 -4
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +20 -0
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +20 -0
- data/lib/familia/encryption.rb +1 -1
- data/lib/familia/errors.rb +17 -3
- data/lib/familia/features/encrypted_fields/concealed_string.rb +293 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
- data/lib/familia/features/encrypted_fields.rb +413 -4
- data/lib/familia/features/expiration.rb +319 -33
- data/lib/familia/features/quantization.rb +385 -44
- data/lib/familia/features/relationships/cascading.rb +438 -0
- data/lib/familia/features/relationships/indexing.rb +370 -0
- data/lib/familia/features/relationships/membership.rb +503 -0
- data/lib/familia/features/relationships/permission_management.rb +264 -0
- data/lib/familia/features/relationships/querying.rb +620 -0
- data/lib/familia/features/relationships/redis_operations.rb +274 -0
- data/lib/familia/features/relationships/score_encoding.rb +442 -0
- data/lib/familia/features/relationships/tracking.rb +379 -0
- data/lib/familia/features/relationships.rb +466 -0
- data/lib/familia/features/safe_dump.rb +1 -1
- data/lib/familia/features/transient_fields/redacted_string.rb +1 -1
- data/lib/familia/features/transient_fields.rb +192 -10
- data/lib/familia/features.rb +2 -1
- data/lib/familia/field_type.rb +5 -2
- data/lib/familia/horreum/{connection.rb → core/connection.rb} +2 -8
- data/lib/familia/horreum/{database_commands.rb → core/database_commands.rb} +14 -3
- data/lib/familia/horreum/core/serialization.rb +535 -0
- data/lib/familia/horreum/{utils.rb → core/utils.rb} +0 -2
- data/lib/familia/horreum/core.rb +21 -0
- data/lib/familia/horreum/{settings.rb → shared/settings.rb} +0 -2
- data/lib/familia/horreum/{definition_methods.rb → subclass/definition.rb} +45 -29
- data/lib/familia/horreum/{management_methods.rb → subclass/management.rb} +9 -8
- data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
- data/lib/familia/horreum.rb +17 -17
- data/lib/familia/validation/command_recorder.rb +336 -0
- data/lib/familia/validation/expectations.rb +519 -0
- data/lib/familia/validation/test_helpers.rb +443 -0
- data/lib/familia/validation/validator.rb +412 -0
- data/lib/familia/validation.rb +140 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +1 -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 -4
- data/try/core/familia_try.rb +1 -1
- data/try/core/persistence_operations_try.rb +297 -0
- data/try/data_types/counter_try.rb +93 -0
- data/try/data_types/lock_try.rb +133 -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/edge_cases/hash_symbolization_try.rb +1 -0
- data/try/edge_cases/reserved_keywords_try.rb +1 -0
- data/try/edge_cases/string_coercion_try.rb +2 -0
- data/try/encryption/encryption_core_try.rb +6 -4
- data/try/features/categorical_permissions_try.rb +515 -0
- data/try/features/encrypted_fields_core_try.rb +19 -11
- data/try/features/encrypted_fields_integration_try.rb +66 -70
- data/try/features/encrypted_fields_no_cache_security_try.rb +22 -8
- data/try/features/encrypted_fields_security_try.rb +151 -144
- data/try/features/encryption_fields/aad_protection_try.rb +108 -23
- data/try/features/encryption_fields/concealed_string_core_try.rb +253 -0
- data/try/features/encryption_fields/context_isolation_try.rb +30 -8
- data/try/features/encryption_fields/error_conditions_try.rb +6 -6
- data/try/features/encryption_fields/fresh_key_derivation_try.rb +20 -14
- data/try/features/encryption_fields/fresh_key_try.rb +27 -22
- data/try/features/encryption_fields/key_rotation_try.rb +16 -10
- data/try/features/encryption_fields/nonce_uniqueness_try.rb +15 -13
- data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
- data/try/features/encryption_fields/thread_safety_try.rb +6 -6
- data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
- data/try/features/feature_dependencies_try.rb +3 -3
- data/try/features/relationships_edge_cases_try.rb +145 -0
- data/try/features/relationships_performance_minimal_try.rb +132 -0
- data/try/features/relationships_performance_simple_try.rb +155 -0
- data/try/features/relationships_performance_try.rb +420 -0
- data/try/features/relationships_performance_working_try.rb +144 -0
- data/try/features/relationships_try.rb +237 -0
- data/try/features/safe_dump_try.rb +3 -0
- data/try/features/transient_fields/redacted_string_try.rb +2 -0
- data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
- data/try/features/transient_fields_core_try.rb +1 -1
- data/try/features/transient_fields_integration_try.rb +1 -1
- data/try/helpers/test_helpers.rb +26 -1
- data/try/horreum/base_try.rb +14 -8
- data/try/horreum/enhanced_conflict_handling_try.rb +3 -1
- data/try/horreum/initialization_try.rb +1 -1
- data/try/horreum/relations_try.rb +2 -2
- data/try/horreum/serialization_persistent_fields_try.rb +8 -8
- data/try/horreum/serialization_try.rb +39 -4
- data/try/models/customer_safe_dump_try.rb +1 -1
- data/try/models/customer_try.rb +1 -1
- data/try/validation/atomic_operations_try.rb.disabled +320 -0
- data/try/validation/command_validation_try.rb.disabled +207 -0
- data/try/validation/performance_validation_try.rb.disabled +324 -0
- data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
- metadata +81 -12
- data/TEST_COVERAGE.md +0 -40
- data/lib/familia/features/relatable_objects.rb +0 -125
- data/lib/familia/horreum/serialization.rb +0 -473
- data/try/features/relatable_objects_try.rb +0 -220
data/lib/familia/data_type.rb
CHANGED
@@ -27,12 +27,14 @@ module Familia
|
|
27
27
|
attr_writer :logical_database, :uri
|
28
28
|
end
|
29
29
|
|
30
|
+
# DataType::ClassMethods
|
31
|
+
#
|
30
32
|
module ClassMethods
|
31
33
|
# To be called inside every class that inherits DataType
|
32
34
|
# +methname+ is the term used for the class and instance methods
|
33
35
|
# that are created for the given +klass+ (e.g. set, list, etc)
|
34
36
|
def register(klass, methname)
|
35
|
-
Familia.
|
37
|
+
Familia.trace :REGISTER, nil, "[#{self}] Registering #{klass} as #{methname.inspect}", caller(1..1) if Familia.debug?
|
36
38
|
|
37
39
|
@registered_types[methname] = klass
|
38
40
|
end
|
@@ -57,16 +59,16 @@ module Familia
|
|
57
59
|
end
|
58
60
|
|
59
61
|
def valid_keys_only(opts)
|
60
|
-
opts.
|
62
|
+
opts.slice(*DataType.valid_options)
|
61
63
|
end
|
62
64
|
|
63
|
-
def
|
64
|
-
@has_relations ||= false
|
65
|
+
def relations?
|
66
|
+
@has_relations ||= false # rubocop:disable ThreadSafety/ClassInstanceVariable
|
65
67
|
end
|
66
68
|
end
|
67
69
|
extend ClassMethods
|
68
70
|
|
69
|
-
attr_reader :keystring, :
|
71
|
+
attr_reader :keystring, :opts
|
70
72
|
attr_writer :dump_method, :load_method
|
71
73
|
|
72
74
|
# +keystring+: If parent is set, this will be used as the suffix
|
@@ -115,7 +117,7 @@ module Familia
|
|
115
117
|
# this point. This would result in a Familia::Problem being raised. So
|
116
118
|
# to be on the safe-side here until we have a better understanding of
|
117
119
|
# the issue, we'll just log the class name for each key-value pair.
|
118
|
-
Familia.
|
120
|
+
Familia.trace :SETTING, nil, " [setting] #{k} #{v.class}", caller(1..1) if Familia.debug?
|
119
121
|
send(:"#{k}=", v) if respond_to? :"#{k}="
|
120
122
|
end
|
121
123
|
|
@@ -199,6 +201,7 @@ module Familia
|
|
199
201
|
@opts[:parent]
|
200
202
|
end
|
201
203
|
|
204
|
+
|
202
205
|
def logical_database
|
203
206
|
@opts[:logical_database] || self.class.logical_database
|
204
207
|
end
|
@@ -0,0 +1,137 @@
|
|
1
|
+
# lib/familia/encryption/encrypted_data.rb
|
2
|
+
|
3
|
+
module Familia
|
4
|
+
module Encryption
|
5
|
+
EncryptedData = Data.define(:algorithm, :nonce, :ciphertext, :auth_tag, :key_version) do
|
6
|
+
# Class methods for parsing and validation
|
7
|
+
def self.valid?(json_string)
|
8
|
+
return true if json_string.nil? # Allow nil values
|
9
|
+
return false unless json_string.kind_of?(::String)
|
10
|
+
|
11
|
+
begin
|
12
|
+
parsed = JSON.parse(json_string, symbolize_names: true)
|
13
|
+
return false unless parsed.is_a?(Hash)
|
14
|
+
|
15
|
+
# Check for required fields
|
16
|
+
required_fields = [:algorithm, :nonce, :ciphertext, :auth_tag, :key_version]
|
17
|
+
result = required_fields.all? { |field| parsed.key?(field) }
|
18
|
+
Familia.ld "[valid?] result: #{result}, parsed: #{parsed}, required: #{required_fields}"
|
19
|
+
result
|
20
|
+
rescue JSON::ParserError => e
|
21
|
+
Familia.ld "[valid?] JSON error: #{e.message}"
|
22
|
+
false
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.validate!(json_string)
|
27
|
+
return nil if json_string.nil?
|
28
|
+
|
29
|
+
unless json_string.kind_of?(::String)
|
30
|
+
raise EncryptionError, "Expected JSON string, got #{json_string.class}"
|
31
|
+
end
|
32
|
+
|
33
|
+
begin
|
34
|
+
parsed = JSON.parse(json_string, symbolize_names: true)
|
35
|
+
rescue JSON::ParserError => e
|
36
|
+
raise EncryptionError, "Invalid JSON structure: #{e.message}"
|
37
|
+
end
|
38
|
+
|
39
|
+
unless parsed.is_a?(Hash)
|
40
|
+
raise EncryptionError, "Expected JSON object, got #{parsed.class}"
|
41
|
+
end
|
42
|
+
|
43
|
+
required_fields = [:algorithm, :nonce, :ciphertext, :auth_tag, :key_version]
|
44
|
+
missing_fields = required_fields.reject { |field| parsed.key?(field) }
|
45
|
+
|
46
|
+
unless missing_fields.empty?
|
47
|
+
raise EncryptionError, "Missing required fields: #{missing_fields.join(', ')}"
|
48
|
+
end
|
49
|
+
|
50
|
+
new(**parsed)
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.from_json(json_string)
|
54
|
+
validate!(json_string)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Instance methods for decryptability validation
|
58
|
+
def decryptable?
|
59
|
+
return false unless algorithm && nonce && ciphertext && auth_tag && key_version
|
60
|
+
|
61
|
+
# Ensure Registry is set up before checking algorithms
|
62
|
+
Registry.setup! if Registry.providers.empty?
|
63
|
+
|
64
|
+
# Check if algorithm is supported
|
65
|
+
return false unless Registry.providers.key?(algorithm)
|
66
|
+
|
67
|
+
# Validate Base64 encoding of binary fields
|
68
|
+
begin
|
69
|
+
Base64.strict_decode64(nonce)
|
70
|
+
Base64.strict_decode64(ciphertext)
|
71
|
+
Base64.strict_decode64(auth_tag)
|
72
|
+
rescue ArgumentError
|
73
|
+
return false
|
74
|
+
end
|
75
|
+
|
76
|
+
true
|
77
|
+
end
|
78
|
+
|
79
|
+
def validate_decryptable!
|
80
|
+
unless algorithm
|
81
|
+
raise EncryptionError, "Missing algorithm field"
|
82
|
+
end
|
83
|
+
|
84
|
+
# Ensure Registry is set up before checking algorithms
|
85
|
+
Registry.setup! if Registry.providers.empty?
|
86
|
+
|
87
|
+
unless Registry.providers.key?(algorithm)
|
88
|
+
raise EncryptionError, "Unsupported algorithm: #{algorithm}"
|
89
|
+
end
|
90
|
+
|
91
|
+
unless nonce && ciphertext && auth_tag && key_version
|
92
|
+
missing = []
|
93
|
+
missing << 'nonce' unless nonce
|
94
|
+
missing << 'ciphertext' unless ciphertext
|
95
|
+
missing << 'auth_tag' unless auth_tag
|
96
|
+
missing << 'key_version' unless key_version
|
97
|
+
raise EncryptionError, "Missing required fields: #{missing.join(', ')}"
|
98
|
+
end
|
99
|
+
|
100
|
+
# Get the provider for size validation
|
101
|
+
provider = Registry.providers[algorithm]
|
102
|
+
|
103
|
+
# Validate Base64 encoding and sizes
|
104
|
+
begin
|
105
|
+
decoded_nonce = Base64.strict_decode64(nonce)
|
106
|
+
if decoded_nonce.bytesize != provider.nonce_size
|
107
|
+
raise EncryptionError, "Invalid nonce size: expected #{provider.nonce_size}, got #{decoded_nonce.bytesize}"
|
108
|
+
end
|
109
|
+
rescue ArgumentError
|
110
|
+
raise EncryptionError, "Invalid Base64 encoding in nonce field"
|
111
|
+
end
|
112
|
+
|
113
|
+
begin
|
114
|
+
Base64.strict_decode64(ciphertext) # ciphertext can be variable size
|
115
|
+
rescue ArgumentError
|
116
|
+
raise EncryptionError, "Invalid Base64 encoding in ciphertext field"
|
117
|
+
end
|
118
|
+
|
119
|
+
begin
|
120
|
+
decoded_auth_tag = Base64.strict_decode64(auth_tag)
|
121
|
+
if decoded_auth_tag.bytesize != provider.auth_tag_size
|
122
|
+
raise EncryptionError, "Invalid auth_tag size: expected #{provider.auth_tag_size}, got #{decoded_auth_tag.bytesize}"
|
123
|
+
end
|
124
|
+
rescue ArgumentError
|
125
|
+
raise EncryptionError, "Invalid Base64 encoding in auth_tag field"
|
126
|
+
end
|
127
|
+
|
128
|
+
# Validate that the key version exists
|
129
|
+
unless Familia.config.encryption_keys&.key?(key_version.to_sym)
|
130
|
+
raise EncryptionError, "No key for version: #{key_version}"
|
131
|
+
end
|
132
|
+
|
133
|
+
self
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
@@ -33,23 +33,28 @@ module Familia
|
|
33
33
|
def decrypt(encrypted_json, context:, additional_data: nil)
|
34
34
|
return nil if encrypted_json.nil? || encrypted_json.empty?
|
35
35
|
|
36
|
+
# Increment counter immediately to track all decryption attempts, even failed ones
|
37
|
+
Familia::Encryption.derivation_count.increment
|
38
|
+
|
36
39
|
begin
|
37
40
|
data = Familia::Encryption::EncryptedData.new(**JSON.parse(encrypted_json, symbolize_names: true))
|
38
41
|
|
39
42
|
# Validate algorithm support
|
40
43
|
provider = Registry.get(data.algorithm)
|
41
|
-
key =
|
44
|
+
key = derive_key_without_increment(context, version: data.key_version, provider: provider)
|
42
45
|
|
43
46
|
# Safely decode and validate sizes
|
44
47
|
nonce = decode_and_validate(data.nonce, provider.nonce_size, 'nonce')
|
45
|
-
ciphertext =
|
48
|
+
ciphertext = decode_and_validate_ciphertext(data.ciphertext)
|
46
49
|
auth_tag = decode_and_validate(data.auth_tag, provider.auth_tag_size, 'auth_tag')
|
47
50
|
|
48
51
|
provider.decrypt(ciphertext, key, nonce, auth_tag, additional_data)
|
49
52
|
rescue EncryptionError
|
50
53
|
raise
|
51
|
-
rescue
|
52
|
-
raise EncryptionError,
|
54
|
+
rescue JSON::ParserError => e
|
55
|
+
raise EncryptionError, "Invalid JSON structure: #{e.message}"
|
56
|
+
rescue StandardError => e
|
57
|
+
raise EncryptionError, "Decryption failed: #{e.message}"
|
53
58
|
end
|
54
59
|
ensure
|
55
60
|
Familia::Encryption.secure_wipe(key) if key
|
@@ -61,12 +66,24 @@ module Familia
|
|
61
66
|
decoded = Base64.strict_decode64(encoded)
|
62
67
|
raise EncryptionError, 'Invalid encrypted data' unless decoded.bytesize == expected_size
|
63
68
|
decoded
|
69
|
+
rescue ArgumentError => e
|
70
|
+
raise EncryptionError, "Invalid Base64 encoding in #{component} field"
|
71
|
+
end
|
72
|
+
|
73
|
+
def decode_and_validate_ciphertext(encoded)
|
74
|
+
Base64.strict_decode64(encoded)
|
75
|
+
rescue ArgumentError => e
|
76
|
+
raise EncryptionError, "Invalid Base64 encoding in ciphertext field"
|
64
77
|
end
|
65
78
|
|
66
79
|
def derive_key(context, version: nil, provider: nil)
|
67
80
|
# Increment counter to prove no caching is happening
|
68
81
|
Familia::Encryption.derivation_count.increment
|
69
82
|
|
83
|
+
derive_key_without_increment(context, version: version, provider: provider)
|
84
|
+
end
|
85
|
+
|
86
|
+
def derive_key_without_increment(context, version: nil, provider: nil)
|
70
87
|
# Use provided provider or fall back to instance provider
|
71
88
|
provider ||= @provider
|
72
89
|
|
@@ -87,6 +87,26 @@ module Familia
|
|
87
87
|
key&.clear
|
88
88
|
end
|
89
89
|
|
90
|
+
def self.nonce_size
|
91
|
+
NONCE_SIZE
|
92
|
+
end
|
93
|
+
|
94
|
+
def self.auth_tag_size
|
95
|
+
AUTH_TAG_SIZE
|
96
|
+
end
|
97
|
+
|
98
|
+
def nonce_size
|
99
|
+
NONCE_SIZE
|
100
|
+
end
|
101
|
+
|
102
|
+
def auth_tag_size
|
103
|
+
AUTH_TAG_SIZE
|
104
|
+
end
|
105
|
+
|
106
|
+
def algorithm
|
107
|
+
ALGORITHM
|
108
|
+
end
|
109
|
+
|
90
110
|
private
|
91
111
|
|
92
112
|
def create_cipher(mode)
|
@@ -106,6 +106,26 @@ module Familia
|
|
106
106
|
key&.clear
|
107
107
|
end
|
108
108
|
|
109
|
+
def self.nonce_size
|
110
|
+
NONCE_SIZE
|
111
|
+
end
|
112
|
+
|
113
|
+
def self.auth_tag_size
|
114
|
+
AUTH_TAG_SIZE
|
115
|
+
end
|
116
|
+
|
117
|
+
def nonce_size
|
118
|
+
NONCE_SIZE
|
119
|
+
end
|
120
|
+
|
121
|
+
def auth_tag_size
|
122
|
+
AUTH_TAG_SIZE
|
123
|
+
end
|
124
|
+
|
125
|
+
def algorithm
|
126
|
+
ALGORITHM
|
127
|
+
end
|
128
|
+
|
109
129
|
private
|
110
130
|
|
111
131
|
def validate_key_length!(key)
|
data/lib/familia/encryption.rb
CHANGED
@@ -10,12 +10,12 @@ require_relative 'encryption/providers/xchacha20_poly1305_provider'
|
|
10
10
|
require_relative 'encryption/providers/aes_gcm_provider'
|
11
11
|
require_relative 'encryption/registry'
|
12
12
|
require_relative 'encryption/manager'
|
13
|
+
require_relative 'encryption/encrypted_data'
|
13
14
|
|
14
15
|
module Familia
|
15
16
|
class EncryptionError < StandardError; end
|
16
17
|
|
17
18
|
module Encryption
|
18
|
-
EncryptedData = Data.define(:algorithm, :nonce, :ciphertext, :auth_tag, :key_version)
|
19
19
|
|
20
20
|
# Smart facade with provider selection and field-specific encryption
|
21
21
|
#
|
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,293 @@
|
|
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.instance_of?(expected_record.class) &&
|
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
|
+
# String methods that should return safe concealed values
|
173
|
+
def upcase
|
174
|
+
'[CONCEALED]'
|
175
|
+
end
|
176
|
+
|
177
|
+
def downcase
|
178
|
+
'[CONCEALED]'
|
179
|
+
end
|
180
|
+
|
181
|
+
def length
|
182
|
+
11 # Fixed concealed length to match '[CONCEALED]' length
|
183
|
+
end
|
184
|
+
|
185
|
+
def size
|
186
|
+
length
|
187
|
+
end
|
188
|
+
|
189
|
+
def present?
|
190
|
+
true # Always return true since encrypted data exists
|
191
|
+
end
|
192
|
+
|
193
|
+
def blank?
|
194
|
+
false # Never blank if encrypted data exists
|
195
|
+
end
|
196
|
+
|
197
|
+
# String concatenation operations return concealed result
|
198
|
+
def +(_other)
|
199
|
+
'[CONCEALED]'
|
200
|
+
end
|
201
|
+
|
202
|
+
def concat(_other)
|
203
|
+
'[CONCEALED]'
|
204
|
+
end
|
205
|
+
|
206
|
+
# Handle coercion for concatenation like "string" + concealed
|
207
|
+
def coerce(other)
|
208
|
+
if other.is_a?(String)
|
209
|
+
['[CONCEALED]', '[CONCEALED]']
|
210
|
+
else
|
211
|
+
[other, '[CONCEALED]']
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# String pattern matching methods
|
216
|
+
def strip
|
217
|
+
'[CONCEALED]'
|
218
|
+
end
|
219
|
+
|
220
|
+
def gsub(*)
|
221
|
+
'[CONCEALED]'
|
222
|
+
end
|
223
|
+
|
224
|
+
def include?(_substring)
|
225
|
+
false # Never reveal substring presence
|
226
|
+
end
|
227
|
+
|
228
|
+
# Enumerable methods for safety
|
229
|
+
def map
|
230
|
+
yield '[CONCEALED]' if block_given?
|
231
|
+
['[CONCEALED]']
|
232
|
+
end
|
233
|
+
|
234
|
+
def each
|
235
|
+
yield '[CONCEALED]' if block_given?
|
236
|
+
self
|
237
|
+
end
|
238
|
+
|
239
|
+
# Safe representation for debugging and console output
|
240
|
+
def inspect
|
241
|
+
'[CONCEALED]'
|
242
|
+
end
|
243
|
+
|
244
|
+
# Hash/Array serialization safety
|
245
|
+
def to_h
|
246
|
+
'[CONCEALED]'
|
247
|
+
end
|
248
|
+
|
249
|
+
def to_a
|
250
|
+
['[CONCEALED]']
|
251
|
+
end
|
252
|
+
|
253
|
+
# Consistent hash to prevent timing attacks
|
254
|
+
def hash
|
255
|
+
ConcealedString.hash
|
256
|
+
end
|
257
|
+
|
258
|
+
# Pattern matching safety (Ruby 3.0+)
|
259
|
+
def deconstruct
|
260
|
+
['[CONCEALED]']
|
261
|
+
end
|
262
|
+
|
263
|
+
def deconstruct_keys(*)
|
264
|
+
{ concealed: true }
|
265
|
+
end
|
266
|
+
|
267
|
+
# Prevent exposure in JSON serialization
|
268
|
+
def to_json(*)
|
269
|
+
'"[CONCEALED]"'
|
270
|
+
end
|
271
|
+
|
272
|
+
# Prevent exposure in Rails serialization (as_json -> to_json)
|
273
|
+
def as_json(*)
|
274
|
+
'[CONCEALED]'
|
275
|
+
end
|
276
|
+
|
277
|
+
# Finalizer to attempt memory cleanup
|
278
|
+
def self.finalizer_proc(encrypted_data)
|
279
|
+
proc do
|
280
|
+
# Best effort cleanup - Ruby doesn't guarantee memory security
|
281
|
+
# Only clear if not frozen to avoid FrozenError
|
282
|
+
encrypted_data.clear if encrypted_data.respond_to?(:clear) && !encrypted_data.frozen?
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
private
|
287
|
+
|
288
|
+
# Check if a string looks like encrypted JSON data
|
289
|
+
def encrypted_json?(data)
|
290
|
+
Familia::Encryption::EncryptedData.valid?(data)
|
291
|
+
end
|
292
|
+
|
293
|
+
end
|