familia 2.0.0.pre5 → 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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +8 -5
  3. data/Gemfile +1 -1
  4. data/Gemfile.lock +4 -3
  5. data/docs/wiki/API-Reference.md +95 -18
  6. data/docs/wiki/Connection-Pooling-Guide.md +437 -0
  7. data/docs/wiki/Encrypted-Fields-Overview.md +40 -3
  8. data/docs/wiki/Expiration-Feature-Guide.md +596 -0
  9. data/docs/wiki/Feature-System-Guide.md +600 -0
  10. data/docs/wiki/Features-System-Developer-Guide.md +892 -0
  11. data/docs/wiki/Field-System-Guide.md +784 -0
  12. data/docs/wiki/Home.md +72 -15
  13. data/docs/wiki/Implementation-Guide.md +126 -33
  14. data/docs/wiki/Quantization-Feature-Guide.md +721 -0
  15. data/docs/wiki/RelatableObjects-Guide.md +563 -0
  16. data/docs/wiki/Security-Model.md +65 -25
  17. data/docs/wiki/Transient-Fields-Guide.md +280 -0
  18. data/lib/familia/base.rb +1 -1
  19. data/lib/familia/data_type/types/counter.rb +38 -0
  20. data/lib/familia/data_type/types/hashkey.rb +18 -0
  21. data/lib/familia/data_type/types/lock.rb +43 -0
  22. data/lib/familia/data_type/types/string.rb +9 -2
  23. data/lib/familia/data_type.rb +2 -2
  24. data/lib/familia/encryption/encrypted_data.rb +137 -0
  25. data/lib/familia/encryption/manager.rb +21 -4
  26. data/lib/familia/encryption/providers/aes_gcm_provider.rb +20 -0
  27. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +20 -0
  28. data/lib/familia/encryption.rb +1 -1
  29. data/lib/familia/errors.rb +17 -3
  30. data/lib/familia/features/encrypted_fields/concealed_string.rb +295 -0
  31. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
  32. data/lib/familia/features/expiration.rb +1 -1
  33. data/lib/familia/features/quantization.rb +1 -1
  34. data/lib/familia/features/safe_dump.rb +1 -1
  35. data/lib/familia/features/transient_fields/redacted_string.rb +1 -1
  36. data/lib/familia/features/transient_fields.rb +1 -1
  37. data/lib/familia/field_type.rb +5 -2
  38. data/lib/familia/horreum/{connection.rb → core/connection.rb} +2 -8
  39. data/lib/familia/horreum/{database_commands.rb → core/database_commands.rb} +14 -3
  40. data/lib/familia/horreum/core/serialization.rb +535 -0
  41. data/lib/familia/horreum/{utils.rb → core/utils.rb} +0 -2
  42. data/lib/familia/horreum/core.rb +21 -0
  43. data/lib/familia/horreum/{settings.rb → shared/settings.rb} +0 -2
  44. data/lib/familia/horreum/{definition_methods.rb → subclass/definition.rb} +44 -28
  45. data/lib/familia/horreum/{management_methods.rb → subclass/management.rb} +9 -8
  46. data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
  47. data/lib/familia/horreum.rb +17 -17
  48. data/lib/familia/version.rb +1 -1
  49. data/lib/familia.rb +1 -1
  50. data/try/core/create_method_try.rb +240 -0
  51. data/try/core/database_consistency_try.rb +299 -0
  52. data/try/core/errors_try.rb +25 -4
  53. data/try/core/familia_try.rb +1 -1
  54. data/try/core/persistence_operations_try.rb +297 -0
  55. data/try/data_types/counter_try.rb +93 -0
  56. data/try/data_types/lock_try.rb +133 -0
  57. data/try/debugging/debug_aad_process.rb +82 -0
  58. data/try/debugging/debug_concealed_internal.rb +59 -0
  59. data/try/debugging/debug_concealed_reveal.rb +61 -0
  60. data/try/debugging/debug_context_aad.rb +68 -0
  61. data/try/debugging/debug_context_simple.rb +80 -0
  62. data/try/debugging/debug_cross_context.rb +62 -0
  63. data/try/debugging/debug_database_load.rb +64 -0
  64. data/try/debugging/debug_encrypted_json_check.rb +53 -0
  65. data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
  66. data/try/debugging/debug_exists_lifecycle.rb +54 -0
  67. data/try/debugging/debug_field_decrypt.rb +74 -0
  68. data/try/debugging/debug_fresh_cross_context.rb +73 -0
  69. data/try/debugging/debug_load_path.rb +66 -0
  70. data/try/debugging/debug_method_definition.rb +46 -0
  71. data/try/debugging/debug_method_resolution.rb +41 -0
  72. data/try/debugging/debug_minimal.rb +24 -0
  73. data/try/debugging/debug_provider.rb +68 -0
  74. data/try/debugging/debug_secure_behavior.rb +73 -0
  75. data/try/debugging/debug_string_class.rb +46 -0
  76. data/try/debugging/debug_test.rb +46 -0
  77. data/try/debugging/debug_test_design.rb +80 -0
  78. data/try/encryption/encryption_core_try.rb +3 -3
  79. data/try/features/encrypted_fields_core_try.rb +19 -11
  80. data/try/features/encrypted_fields_integration_try.rb +66 -70
  81. data/try/features/encrypted_fields_no_cache_security_try.rb +22 -8
  82. data/try/features/encrypted_fields_security_try.rb +151 -144
  83. data/try/features/encryption_fields/aad_protection_try.rb +108 -23
  84. data/try/features/encryption_fields/concealed_string_core_try.rb +250 -0
  85. data/try/features/encryption_fields/context_isolation_try.rb +29 -8
  86. data/try/features/encryption_fields/error_conditions_try.rb +6 -6
  87. data/try/features/encryption_fields/fresh_key_derivation_try.rb +20 -14
  88. data/try/features/encryption_fields/fresh_key_try.rb +27 -22
  89. data/try/features/encryption_fields/key_rotation_try.rb +16 -10
  90. data/try/features/encryption_fields/nonce_uniqueness_try.rb +15 -13
  91. data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
  92. data/try/features/encryption_fields/thread_safety_try.rb +6 -6
  93. data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
  94. data/try/features/feature_dependencies_try.rb +3 -3
  95. data/try/features/transient_fields_core_try.rb +1 -1
  96. data/try/features/transient_fields_integration_try.rb +1 -1
  97. data/try/helpers/test_helpers.rb +25 -0
  98. data/try/horreum/enhanced_conflict_handling_try.rb +1 -1
  99. data/try/horreum/initialization_try.rb +1 -1
  100. data/try/horreum/relations_try.rb +1 -1
  101. data/try/horreum/serialization_persistent_fields_try.rb +8 -8
  102. data/try/horreum/serialization_try.rb +39 -4
  103. data/try/models/customer_safe_dump_try.rb +1 -1
  104. data/try/models/customer_try.rb +1 -1
  105. metadata +51 -10
  106. data/TEST_COVERAGE.md +0 -40
  107. data/lib/familia/horreum/serialization.rb +0 -473
@@ -0,0 +1,280 @@
1
+ # Transient Fields Guide
2
+
3
+ ## Overview
4
+
5
+ Transient fields provide secure handling of sensitive runtime data that should never be persisted to Redis/Valkey. Unlike encrypted fields, transient fields exist only in memory and are automatically wrapped in `RedactedString` for security.
6
+
7
+ ## When to Use Transient Fields
8
+
9
+ Use transient fields for:
10
+ - API keys and tokens that change frequently
11
+ - Temporary passwords or passphrases
12
+ - Session-specific secrets
13
+ - Any sensitive data that should never touch persistent storage
14
+ - Debug or development secrets that need secure handling
15
+
16
+ ## Basic Usage
17
+
18
+ ### Define Transient Fields
19
+
20
+ ```ruby
21
+ class ApiClient < Familia::Horreum
22
+ feature :transient_fields
23
+
24
+ field :endpoint # Regular persistent field
25
+ transient_field :token # Transient field (not persisted)
26
+ transient_field :secret, as: :api_secret # Custom accessor name
27
+ end
28
+ ```
29
+
30
+ ### Working with Transient Fields
31
+
32
+ ```ruby
33
+ client = ApiClient.new(
34
+ endpoint: 'https://api.example.com',
35
+ token: ENV['API_TOKEN'],
36
+ secret: ENV['API_SECRET']
37
+ )
38
+
39
+ # Regular field persists
40
+ client.save
41
+ client.endpoint # => "https://api.example.com"
42
+
43
+ # Transient fields are RedactedString instances
44
+ puts client.token # => "[REDACTED]"
45
+
46
+ # Access the actual value safely
47
+ client.token.expose do |token|
48
+ response = HTTP.post(client.endpoint,
49
+ headers: { 'Authorization' => "Bearer #{token}" }
50
+ )
51
+ # Token value is only available within this block
52
+ end
53
+
54
+ # Explicit cleanup when done
55
+ client.token.clear!
56
+ ```
57
+
58
+ ## RedactedString Security
59
+
60
+ ### Automatic Wrapping
61
+
62
+ All transient field values are automatically wrapped in `RedactedString`:
63
+
64
+ ```ruby
65
+ client = ApiClient.new(token: 'secret123')
66
+ client.token.class # => RedactedString
67
+ ```
68
+
69
+ ### Safe Access Pattern
70
+
71
+ ```ruby
72
+ # ✅ Recommended: Use .expose block
73
+ client.token.expose do |token|
74
+ # Use token directly without creating copies
75
+ HTTP.auth("Bearer #{token}") # Safe
76
+ end
77
+
78
+ # ✅ Direct access (use carefully)
79
+ raw_token = client.token.value
80
+ # Remember to clear original source if needed
81
+
82
+ # ❌ Avoid: These create uncontrolled copies
83
+ token_copy = client.token.value.dup # Creates copy in memory
84
+ interpolated = "Bearer #{client.token}" # Creates copy via to_s
85
+ ```
86
+
87
+ ### Memory Management
88
+
89
+ ```ruby
90
+ # Clear individual fields
91
+ client.token.clear!
92
+
93
+ # Check if cleared
94
+ client.token.cleared? # => true
95
+
96
+ # Accessing cleared values raises error
97
+ client.token.value # => SecurityError: Value already cleared
98
+ ```
99
+
100
+ ## Advanced Features
101
+
102
+ ### Custom Accessor Names
103
+
104
+ ```ruby
105
+ class Service < Familia::Horreum
106
+ transient_field :api_key, as: :secret_key
107
+ end
108
+
109
+ service = Service.new(api_key: 'secret123')
110
+ service.secret_key.expose { |key| use_api_key(key) }
111
+ ```
112
+
113
+ ### Integration with Encrypted Fields
114
+
115
+ ```ruby
116
+ class SecureService < Familia::Horreum
117
+ feature :transient_fields
118
+
119
+ encrypted_field :long_term_secret # Persisted, encrypted
120
+ transient_field :session_token # Runtime only, not persisted
121
+ field :public_endpoint # Normal field
122
+ end
123
+
124
+ service = SecureService.new(
125
+ long_term_secret: 'stored encrypted in Redis',
126
+ session_token: 'temporary runtime secret',
127
+ public_endpoint: 'https://api.example.com'
128
+ )
129
+
130
+ service.save
131
+ # Only long_term_secret and public_endpoint are saved to Redis
132
+ # session_token exists only in memory
133
+ ```
134
+
135
+ ## RedactedString API Reference
136
+
137
+ ### Core Methods
138
+
139
+ ```ruby
140
+ # Create (usually automatic via transient_field)
141
+ secret = RedactedString.new('sensitive_value')
142
+
143
+ # Safe access
144
+ secret.expose { |value| use_value(value) }
145
+
146
+ # Direct access (use with caution)
147
+ value = secret.value
148
+
149
+ # Cleanup
150
+ secret.clear!
151
+
152
+ # Status
153
+ secret.cleared? # => true/false
154
+ ```
155
+
156
+ ### Security Methods
157
+
158
+ ```ruby
159
+ # Logging/debugging protection
160
+ puts secret.to_s # => "[REDACTED]"
161
+ puts secret.inspect # => "[REDACTED]"
162
+
163
+ # Equality (object identity only)
164
+ secret1 == secret2 # => false (unless same object)
165
+
166
+ # Hash (constant for all instances)
167
+ secret.hash # => Same for all RedactedString instances
168
+ ```
169
+
170
+ ## Security Considerations
171
+
172
+ ### Ruby Memory Limitations
173
+
174
+ **⚠️ Important**: Ruby provides no memory safety guarantees:
175
+
176
+ - **No secure wiping**: `.clear!` is best-effort only
177
+ - **GC copying**: Garbage collector may duplicate secrets
178
+ - **String operations**: Every manipulation creates copies
179
+ - **Memory persistence**: Secrets may remain in memory indefinitely
180
+
181
+ ### Best Practices
182
+
183
+ ```ruby
184
+ # ✅ Wrap immediately after input
185
+ password = RedactedString.new(params[:password])
186
+ params[:password] = nil # Clear original reference
187
+
188
+ # ✅ Use .expose for short operations
189
+ token.expose { |t| api_call(t) }
190
+
191
+ # ✅ Clear explicitly when done
192
+ token.clear!
193
+
194
+ # ✅ Avoid string operations that create copies
195
+ token.expose { |t| "Bearer #{t}" } # Creates copy
196
+ # Better: Pass token directly to methods that need it
197
+
198
+ # ❌ Don't pass RedactedString to logging
199
+ logger.info "Token: #{token}" # Still logs "[REDACTED]" but safer to avoid
200
+
201
+ # ❌ Don't store in instance variables outside field system
202
+ @raw_token = token.value # Creates uncontrolled copy
203
+ ```
204
+
205
+ ### Production Recommendations
206
+
207
+ For highly sensitive applications, consider:
208
+ - External secrets management (HashiCorp Vault, AWS Secrets Manager)
209
+ - Hardware Security Modules (HSMs)
210
+ - Languages with secure memory handling
211
+ - Encrypted swap and memory protection at OS level
212
+
213
+ ## Integration Examples
214
+
215
+ ### Rails Controller
216
+
217
+ ```ruby
218
+ class ApiController < ApplicationController
219
+ def authenticate
220
+ service = ApiService.new(
221
+ endpoint: params[:endpoint],
222
+ token: params[:token] # Auto-wrapped in RedactedString
223
+ )
224
+
225
+ result = service.token.expose do |token|
226
+ # Token only accessible within this block
227
+ ExternalAPI.authenticate(token)
228
+ end
229
+
230
+ # Clear token when request is done
231
+ service.token.clear!
232
+
233
+ render json: { status: result }
234
+ end
235
+ end
236
+ ```
237
+
238
+ ### Background Job
239
+
240
+ ```ruby
241
+ class ApiSyncJob
242
+ def perform(user_id, token)
243
+ user = User.find(user_id)
244
+
245
+ # Wrap external token securely
246
+ secure_token = RedactedString.new(token)
247
+ token.clear if token.respond_to?(:clear) # Clear original
248
+
249
+ client = ApiClient.new(token: secure_token)
250
+
251
+ begin
252
+ sync_data(client)
253
+ ensure
254
+ client.token.clear! # Always cleanup
255
+ end
256
+ end
257
+
258
+ private
259
+
260
+ def sync_data(client)
261
+ client.token.expose do |token|
262
+ # Use token for API calls
263
+ fetch_and_process_data(token)
264
+ end
265
+ end
266
+ end
267
+ ```
268
+
269
+ ## Comparison with Encrypted Fields
270
+
271
+ | Feature | Encrypted Fields | Transient Fields |
272
+ |---------|------------------|------------------|
273
+ | **Persistence** | Saved to Redis/Valkey | Memory only |
274
+ | **Encryption** | AES/XChaCha20 | None (not stored) |
275
+ | **Use Case** | Long-term secrets | Runtime secrets |
276
+ | **Access** | Automatic decrypt | RedactedString wrapper |
277
+ | **Performance** | Crypto overhead | No crypto operations |
278
+ | **Lifecycle** | Survives restarts | Cleared on restart |
279
+
280
+ Choose encrypted fields for data that must persist across sessions. Choose transient fields for sensitive runtime data that should never be stored.
data/lib/familia/base.rb CHANGED
@@ -35,7 +35,7 @@ module Familia
35
35
 
36
36
  def add_feature(klass, feature_name, depends_on: [])
37
37
  @features_available ||= {}
38
- Familia.ld "[#{self}] Adding feature #{klass} as #{feature_name.inspect}"
38
+ Familia.trace :ADD_FEATURE, klass, feature_name, caller(1..1) if Familia.debug?
39
39
 
40
40
  # Create field definition object
41
41
  feature_def = FeatureDefinition.new(
@@ -0,0 +1,38 @@
1
+ # lib/familia/data_type/types/counter.rb
2
+
3
+ module Familia
4
+ class Counter < String
5
+ def initialize(*args)
6
+ super
7
+ @opts[:default] ||= 0
8
+ end
9
+
10
+ # Enhanced counter semantics
11
+ def reset(val = 0)
12
+ set(val).to_s.eql?('OK')
13
+ end
14
+
15
+ def increment_if_less_than(threshold, amount = 1)
16
+ current = to_i
17
+ return false if current >= threshold
18
+
19
+ incrementby(amount)
20
+ true
21
+ end
22
+
23
+ def atomic_increment_and_get(amount = 1)
24
+ incrementby(amount)
25
+ end
26
+
27
+ # Override to ensure integer serialization
28
+ def value=(val)
29
+ super(val.to_i)
30
+ end
31
+
32
+ def value
33
+ super.to_i
34
+ end
35
+ end
36
+ end
37
+
38
+ Familia::DataType.register Familia::Counter, :counter
@@ -61,6 +61,24 @@ module Familia
61
61
  end
62
62
  alias all hgetall
63
63
 
64
+ # Sets field in the hash stored at key to value, only if field does not yet exist.
65
+ # If field already exists, this operation has no effect.
66
+ # @param field [String] The field name
67
+ # @param val [Object] The value to set
68
+ # @return [Integer] 1 if field is a new field and value was set, 0 if field already exists
69
+ def hsetnx(field, val)
70
+ ret = dbclient.hsetnx dbkey, field.to_s, serialize_value(val)
71
+ update_expiration if ret == 1
72
+ ret
73
+ rescue TypeError => e
74
+ Familia.le "[hsetnx] #{e.message}"
75
+ Familia.ld "[hsetnx] #{dbkey} #{field}=#{val}" if Familia.debug
76
+ echo :hsetnx, caller(1..1).first if Familia.debug # logs via echo to the db and back
77
+ klass = val.class
78
+ msg = "Cannot store #{field} => #{val.inspect} (#{klass}) in #{dbkey}"
79
+ raise e.class, msg
80
+ end
81
+
64
82
  def key?(field)
65
83
  dbclient.hexists dbkey, field.to_s
66
84
  end
@@ -0,0 +1,43 @@
1
+ # lib/familia/data_type/types/lock.rb
2
+
3
+ module Familia
4
+ class Lock < String
5
+ def initialize(*args)
6
+ super
7
+ @opts[:default] = nil
8
+ end
9
+
10
+ # Acquire a lock with optional TTL
11
+ # @param token [String] Unique token to identify lock holder (auto-generated if nil)
12
+ # @param ttl [Integer, nil] Time-to-live in seconds. nil = no expiration, <=0 rejected
13
+ # @return [String, false] Returns token if acquired successfully, false otherwise
14
+ def acquire(token = SecureRandom.uuid, ttl: 10)
15
+ success = setnx(token)
16
+ # Handle both integer (1/0) and boolean (true/false) return values
17
+ return false unless success == 1 || success == true
18
+ return del && false if ttl&.<=(0)
19
+ return del && false if ttl&.positive? && !expire(ttl)
20
+ token
21
+ end
22
+
23
+ def release(token)
24
+ # Lua script to atomically check token and delete
25
+ script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"
26
+ dbclient.eval(script, [dbkey], [token]) == 1
27
+ end
28
+
29
+ def locked?
30
+ !value.nil?
31
+ end
32
+
33
+ def held_by?(token)
34
+ value == token
35
+ end
36
+
37
+ def force_unlock!
38
+ del
39
+ end
40
+ end
41
+ end
42
+
43
+ Familia::DataType.register Familia::Lock, :lock
@@ -108,12 +108,19 @@ module Familia
108
108
  ret
109
109
  end
110
110
 
111
+ def del
112
+ ret = dbclient.del dbkey
113
+ ret.positive?
114
+ end
115
+
111
116
  def nil?
112
117
  value.nil?
113
118
  end
114
119
 
115
120
  Familia::DataType.register self, :string
116
- Familia::DataType.register self, :counter
117
- Familia::DataType.register self, :lock
118
121
  end
119
122
  end
123
+
124
+ # Both subclass String
125
+ require_relative 'lock'
126
+ require_relative 'counter'
@@ -32,7 +32,7 @@ module Familia
32
32
  # +methname+ is the term used for the class and instance methods
33
33
  # that are created for the given +klass+ (e.g. set, list, etc)
34
34
  def register(klass, methname)
35
- Familia.ld "[#{self}] Registering #{klass} as #{methname.inspect}"
35
+ Familia.trace :REGISTER, nil, "[#{self}] Registering #{klass} as #{methname.inspect}", caller(1..1) if Familia.debug?
36
36
 
37
37
  @registered_types[methname] = klass
38
38
  end
@@ -115,7 +115,7 @@ module Familia
115
115
  # this point. This would result in a Familia::Problem being raised. So
116
116
  # to be on the safe-side here until we have a better understanding of
117
117
  # the issue, we'll just log the class name for each key-value pair.
118
- Familia.ld " [setting] #{k} #{v.class}"
118
+ Familia.trace :SETTING, nil, " [setting] #{k} #{v.class}", caller(1..1) if Familia.debug?
119
119
  send(:"#{k}=", v) if respond_to? :"#{k}="
120
120
  end
121
121
 
@@ -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 = derive_key(context, version: data.key_version, provider: provider)
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 = Base64.strict_decode64(data.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 StandardError
52
- raise EncryptionError, 'Decryption failed'
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)
@@ -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
  #