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.
- checksums.yaml +4 -4
- data/CLAUDE.md +8 -5
- data/Gemfile +1 -1
- 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 +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 +72 -15
- data/docs/wiki/Implementation-Guide.md +126 -33
- data/docs/wiki/Quantization-Feature-Guide.md +721 -0
- data/docs/wiki/RelatableObjects-Guide.md +563 -0
- data/docs/wiki/Security-Model.md +65 -25
- data/docs/wiki/Transient-Fields-Guide.md +280 -0
- data/lib/familia/base.rb +1 -1
- 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 +2 -2
- 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 +295 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
- data/lib/familia/features/expiration.rb +1 -1
- data/lib/familia/features/quantization.rb +1 -1
- 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 +1 -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} +44 -28
- 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/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/encryption/encryption_core_try.rb +3 -3
- 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 +250 -0
- data/try/features/encryption_fields/context_isolation_try.rb +29 -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/transient_fields_core_try.rb +1 -1
- data/try/features/transient_fields_integration_try.rb +1 -1
- data/try/helpers/test_helpers.rb +25 -0
- data/try/horreum/enhanced_conflict_handling_try.rb +1 -1
- data/try/horreum/initialization_try.rb +1 -1
- data/try/horreum/relations_try.rb +1 -1
- 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
- metadata +51 -10
- data/TEST_COVERAGE.md +0 -40
- 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.
|
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'
|
data/lib/familia/data_type.rb
CHANGED
@@ -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.
|
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.
|
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 =
|
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
|
#
|