familia 2.3.2 → 2.3.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cfb3f3aa06c0b96493a86fbe2e3c475e81b9a84d69d7b88e06025adbb597ad8e
4
- data.tar.gz: c567f15b73c06a67baad7b1aff36e23d0d544de1a25308716f6edbac235a3ca9
3
+ metadata.gz: 715c43e8b2b403b23fdb6ffbb33c8558a8dbccb6c9cc9dc80a5892a193cdf8c6
4
+ data.tar.gz: 89a9fafdd21877562178d8b4b103f7fe119b6ee12d50ebec9397691bffbf3094
5
5
  SHA512:
6
- metadata.gz: 66028adaf4e3004b57bb17b39a99745ad771b2af915253cce02a6ca130e028cdf39e0fc11d90c40729ffa85cc5b5fa8c2c2f1b0320a7c3e21ea74858d173c6b6
7
- data.tar.gz: aa3c991ea06b0c2268034784b83fb2dfb5ab6507902de3bca2345149dba3bdb287829769abbb15350c093551c79419c3e2e79558cacac2e589161ebdadd9dd4f
6
+ metadata.gz: b05dcefb1c009d499f6f41918fe01e29dc7604468365f0b1b6de02ea61544b3948d2e4aa3d8909069b833d413e915a6e489af0228fa4976299b9d00f074a4e3c
7
+ data.tar.gz: 0b90e00e99299f33f48a689a58dca2a941d3db7bf53c455990a8989768aca41f0837941db7ea71a0568b9f2598f4777acff5570d834d2e8ac188112cc6a67d15
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,47 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.3.3:
11
+
12
+ 2.3.3 — 2026-03-30
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Encrypt now records the original plaintext encoding in the EncryptedData
19
+ envelope (``encoding`` field), completing the Phase 2 encoding round-trip
20
+ fix. Decrypt (Phase 1, #228) already falls back to UTF-8 when the field
21
+ is absent, so this change is backward-compatible. PR #229
22
+
23
+ Fixed
24
+ -----
25
+
26
+ - ``build_aad`` now produces consistent AAD (Additional Authenticated Data)
27
+ regardless of whether the record has been persisted. Previously, encrypted
28
+ fields with ``aad_fields`` used different AAD computation paths before and
29
+ after save, making ``reveal`` fail on any record created via ``create!``.
30
+ Issue #232, PR #234. No migration needed — the previous behavior was
31
+ broken (AAD mismatch prevented decryption), so no valid ciphertexts
32
+ exist under the old inconsistent paths.
33
+
34
+ - ``build_aad`` no longer uses ``.compact`` on AAD field values. Previously,
35
+ nil fields were silently dropped, shifting later values left and producing
36
+ a different hash once the field was populated. Now each field is coerced
37
+ via ``.to_s`` so that nil and empty string both occupy a fixed position
38
+ in the joined AAD string. Issue #232, PR #234.
39
+
40
+ AI Assistance
41
+ -------------
42
+
43
+ - Implementation and test authoring delegated to backend-dev agents, with
44
+ orchestration and Phase 1 test fixups handled in the main session. Claude
45
+ Opus 4.6.
46
+
47
+ - Claude assisted with implementing the fix, updating affected tests, and
48
+ writing the round-trip regression test. The issue analysis, root cause
49
+ identification, and suggested fix were provided in the issue by the author.
50
+
10
51
  .. _changelog-2.3.2:
11
52
 
12
53
  2.3.2 — 2026-03-12
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (2.3.2)
4
+ familia (2.3.3)
5
5
  concurrent-ruby (~> 1.3)
6
6
  connection_pool (>= 2.4, < 4.0)
7
7
  csv (~> 3.3)
@@ -5,6 +5,7 @@ Indexing provides O(1) field-to-object lookups using Redis data structures, enab
5
5
  ## Core Concepts
6
6
 
7
7
  Indexing creates fast lookups for finding objects by field values:
8
+
8
9
  - **O(1) performance** - Hash/Set-based constant-time access
9
10
  - **Automatic management** - Class indexes update on save/destroy
10
11
  - **Flexible scoping** - Global or parent-scoped uniqueness
@@ -12,12 +13,12 @@ Indexing creates fast lookups for finding objects by field values:
12
13
 
13
14
  ## Index Types
14
15
 
15
- | Type | Scope | Use Case | Structure |
16
- |------|-------|----------|-----------|
17
- | `unique_index` | Class | Global unique fields | Redis HashKey |
18
- | `unique_index` | Instance | Parent-scoped unique | Redis HashKey |
19
- | `multi_index` | Class (default) | Global non-unique groupings | Redis Set per value |
20
- | `multi_index` | Instance | Parent-scoped groupings | Redis Set per value |
16
+ | Type | Scope | Use Case | Structure |
17
+ | -------------- | --------------- | --------------------------- | ------------------- |
18
+ | `unique_index` | Class | Global unique fields | Redis HashKey |
19
+ | `unique_index` | Instance | Parent-scoped unique | Redis HashKey |
20
+ | `multi_index` | Class (default) | Global non-unique groupings | Redis Set per value |
21
+ | `multi_index` | Instance | Parent-scoped groupings | Redis Set per value |
21
22
 
22
23
  ## Class-Level Unique Indexing
23
24
 
@@ -47,12 +48,12 @@ User.find_by_email('alice.smith@example.com') # => nil
47
48
 
48
49
  ### Generated Methods
49
50
 
50
- | Method | Description |
51
- |--------|-------------|
52
- | `User.find_by_email(email)` | O(1) lookup |
53
- | `User.index_email_for(user)` | Manual index |
51
+ | Method | Description |
52
+ | ------------------------------ | ----------------- |
53
+ | `User.find_by_email(email)` | O(1) lookup |
54
+ | `User.index_email_for(user)` | Manual index |
54
55
  | `User.unindex_email_for(user)` | Remove from index |
55
- | `User.reindex_email_for(user)` | Update index |
56
+ | `User.reindex_email_for(user)` | Update index |
56
57
 
57
58
  ## Instance-Scoped Unique Indexing
58
59
 
@@ -139,20 +140,20 @@ Customer.role_index_for('user').dbkey # => "customer:role_index:user"
139
140
 
140
141
  ### Generated Class Methods
141
142
 
142
- | Method | Description |
143
- |--------|-------------|
144
- | `Customer.role_index_for(value)` | Factory returning `Familia::UnsortedSet` for the field value |
145
- | `Customer.find_all_by_role(value)` | Find all objects with that field value |
146
- | `Customer.sample_from_role(value, count)` | Random sample of objects |
147
- | `Customer.rebuild_role_index` | Rebuild the entire index from source data |
143
+ | Method | Description |
144
+ | ----------------------------------------- | ------------------------------------------------------------ |
145
+ | `Customer.role_index_for(value)` | Factory returning `Familia::UnsortedSet` for the field value |
146
+ | `Customer.find_all_by_role(value)` | Find all objects with that field value |
147
+ | `Customer.sample_from_role(value, count)` | Random sample of objects |
148
+ | `Customer.rebuild_role_index` | Rebuild the entire index from source data |
148
149
 
149
150
  ### Generated Instance Methods
150
151
 
151
- | Method | Description |
152
- |--------|-------------|
153
- | `customer.add_to_class_role_index` | Add this object to its field value's index |
154
- | `customer.remove_from_class_role_index` | Remove this object from its field value's index |
155
- | `customer.update_in_class_role_index(old_value)` | Move object from old index to new index |
152
+ | Method | Description |
153
+ | ------------------------------------------------ | ----------------------------------------------- |
154
+ | `customer.add_to_class_role_index` | Add this object to its field value's index |
155
+ | `customer.remove_from_class_role_index` | Remove this object from its field value's index |
156
+ | `customer.update_in_class_role_index(old_value)` | Move object from old index to new index |
156
157
 
157
158
  ### Update Operations
158
159
 
@@ -271,24 +272,28 @@ todays_events = user.find_all_by_daily_partition(today)
271
272
  ### Class vs Instance Scoping
272
273
 
273
274
  **Class-level unique (`unique_index :email, :email_lookup`):**
275
+
274
276
  - Automatic indexing on save/destroy
275
277
  - System-wide uniqueness
276
278
  - No parent context needed
277
279
  - Examples: emails, usernames, API keys
278
280
 
279
281
  **Class-level multi (`multi_index :role, :role_index`):**
282
+
280
283
  - Default behavior (no `within:` needed)
281
284
  - Groups all objects by field value at class level
282
285
  - Manual indexing via instance methods
283
286
  - Examples: roles, categories, statuses
284
287
 
285
288
  **Instance-scoped (`unique_index :badge, :badge_index, within: Company`):**
289
+
286
290
  - Manual indexing required
287
291
  - Unique within parent only
288
292
  - Requires parent context
289
293
  - Examples: employee IDs, project names per team
290
294
 
291
295
  **Instance-scoped multi (`multi_index :dept, :dept_index, within: Company`):**
296
+
292
297
  - Groups objects by field value within parent scope
293
298
  - Same field value allowed across different parents
294
299
  - Manual indexing with parent context
@@ -297,11 +302,13 @@ todays_events = user.find_all_by_daily_partition(today)
297
302
  ### Unique vs Multi Indexing
298
303
 
299
304
  **Unique index (`unique_index`):**
305
+
300
306
  - 1:1 field-to-object mapping
301
307
  - Returns single object or nil
302
308
  - Enforces uniqueness within scope
303
309
 
304
310
  **Multi index (`multi_index`):**
311
+
305
312
  - 1:many field-to-objects mapping
306
313
  - Returns array of objects
307
314
  - Allows duplicate values
@@ -325,6 +332,7 @@ These methods work because **indexes are derived data** - they're computed from
325
332
  > **Important:** Participation data (like `@team.members`) cannot be rebuilt automatically because participations represent business decisions, not derived data. See [Why Participations Can't Be Rebuilt](../../lib/familia/features/relationships/participation/rebuild_strategies.md) for the critical distinction between indexes and participations.
326
333
 
327
334
  **When to rebuild indexes:**
335
+
328
336
  - After data migrations or bulk imports
329
337
  - Recovering from index corruption
330
338
  - Adding indexes to existing data
@@ -372,26 +380,29 @@ Index values (the object identifiers stored in hash keys and sets) are raw strin
372
380
 
373
381
  ## Redis Key Patterns
374
382
 
375
- | Type | Pattern | Example |
376
- |------|---------|---------|
377
- | Class unique | `{class}:{index_name}` | `user:email_lookup` |
378
- | Class multi | `{class}:{index_name}:{value}` | `customer:role_index:admin` |
379
- | Instance unique | `{scope}:{id}:{index_name}` | `company:123:badge_index` |
380
- | Instance multi | `{scope}:{id}:{index_name}:{value}` | `company:123:dept_index:engineering` |
383
+ | Type | Pattern | Example |
384
+ | --------------- | ----------------------------------- | ------------------------------------ |
385
+ | Class unique | `{class}:{index_name}` | `user:email_lookup` |
386
+ | Class multi | `{class}:{index_name}:{value}` | `customer:role_index:admin` |
387
+ | Instance unique | `{scope}:{id}:{index_name}` | `company:123:badge_index` |
388
+ | Instance multi | `{scope}:{id}:{index_name}:{value}` | `company:123:dept_index:engineering` |
381
389
 
382
390
  ## Troubleshooting
383
391
 
384
392
  ### Common Issues
385
393
 
386
394
  **Query methods not generated:**
395
+
387
396
  - Check `query: true` (default) or explicitly set
388
397
  - Verify `feature :relationships` declared
389
398
 
390
399
  **Index not updating:**
400
+
391
401
  - Class indexes: automatic on save/destroy
392
402
  - Instance indexes: require manual `add_to_*` calls
393
403
 
394
404
  **Duplicate key errors:**
405
+
395
406
  - Use `multi_index` for non-unique values
396
407
  - Consider instance-scoped for contextual uniqueness
397
408
 
@@ -15,7 +15,8 @@ module Familia
15
15
  end
16
16
 
17
17
  def encrypt(plaintext, context:, additional_data: nil)
18
- return nil if plaintext.to_s.empty?
18
+ plaintext = plaintext.to_s
19
+ return nil if plaintext.empty?
19
20
 
20
21
  key = derive_key(context)
21
22
 
@@ -26,7 +27,8 @@ module Familia
26
27
  nonce: Base64.strict_encode64(result[:nonce]),
27
28
  ciphertext: Base64.strict_encode64(result[:ciphertext]),
28
29
  auth_tag: Base64.strict_encode64(result[:auth_tag]),
29
- key_version: current_key_version
30
+ key_version: current_key_version,
31
+ encoding: plaintext.encoding.name,
30
32
  ).to_h
31
33
 
32
34
  Familia::JsonSerializer.dump(encrypted_data)
@@ -35,7 +37,9 @@ module Familia
35
37
  end
36
38
 
37
39
  def decrypt(encrypted_json_or_hash, context:, additional_data: nil)
38
- return nil if encrypted_json_or_hash.nil? || (encrypted_json_or_hash.respond_to?(:empty?) && encrypted_json_or_hash.empty?)
40
+ if encrypted_json_or_hash.nil? || (encrypted_json_or_hash.respond_to?(:empty?) && encrypted_json_or_hash.empty?)
41
+ return nil
42
+ end
39
43
 
40
44
  # Increment counter immediately to track all decryption attempts, even failed ones
41
45
  Familia::Encryption.derivation_count.increment
@@ -221,16 +221,18 @@ module Familia
221
221
  if @aad_fields.empty?
222
222
  # When no AAD fields specified, use class:field:identifier
223
223
  base_components.join(':')
224
- elsif record.exists?
225
- # For unsaved records, don't enforce AAD fields since they can change
226
- # For saved records, include field values for tamper protection
227
- values = @aad_fields.map { |field| record.send(field) }
228
- all_components = [*base_components, *values].compact
229
- Digest::SHA256.hexdigest(all_components.join(':'))
230
- # Include specified field values in AAD for persisted records
231
224
  else
232
- # For unsaved records, only use class:field:identifier for context isolation
233
- base_components.join(':')
225
+ # Always include aad_field values regardless of persistence state.
226
+ # The field values are available on the record before save and must
227
+ # produce identical AAD at both encrypt and decrypt time.
228
+ #
229
+ # .to_s coerces nil to "" so that every declared AAD field occupies
230
+ # a fixed position in the join. Without this, a nil field would
231
+ # shift later values left and produce a different hash once the
232
+ # field is populated — making existing ciphertext undecryptable.
233
+ values = @aad_fields.map { |field| record.send(field).to_s }
234
+ all_components = [*base_components, *values]
235
+ Digest::SHA256.hexdigest(all_components.join(':'))
234
236
  end
235
237
  end
236
238
  end
@@ -275,12 +275,11 @@ module Familia
275
275
  # Optionally updates the key's expiration time if the feature is enabled
276
276
  # for the object's class.
277
277
  #
278
- # Unlike +save+, this method does NOT add the object to the class-level
279
- # +instances+ sorted set, does not run +prepare_for_save+ (timestamps,
280
- # unique index guards), and does not update class indexes. Use this for
281
- # updating fields on an object that is already persisted and tracked.
282
- # If the object's hash key was created via +commit_fields+ alone, it
283
- # will exist in the DB but won't appear in +instances.to_a+ listings.
278
+ # Unlike +save+, this method does not run +prepare_for_save+ (timestamps,
279
+ # unique index guards) and does not update class indexes. It does update
280
+ # the class-level +instances+ sorted set via +touch_instances!+, so the
281
+ # object will appear in +instances.to_a+ listings. Use this for updating
282
+ # fields on an object that is already persisted and tracked.
284
283
  #
285
284
  # @param update_expiration [Boolean] Whether to update the expiration time
286
285
  # of the Valkey key. Defaults to true.
@@ -4,5 +4,5 @@
4
4
 
5
5
  module Familia
6
6
  # Version information for the Familia
7
- VERSION = '2.3.2'
7
+ VERSION = '2.3.3'
8
8
  end
@@ -0,0 +1,114 @@
1
+ # try/features/encrypted_fields/aad_nil_fields_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ # Verifies build_aad behavior when AAD field values are nil or empty.
6
+ # Covers edge cases around nil vs empty string AAD consistency.
7
+
8
+ require 'base64'
9
+ require 'digest'
10
+ require_relative '../../support/helpers/test_helpers'
11
+
12
+ test_keys = {
13
+ v1: Base64.strict_encode64('a' * 32),
14
+ }
15
+ Familia.config.encryption_keys = test_keys
16
+ Familia.config.current_key_version = :v1
17
+
18
+ class AADNilFieldModel < Familia::Horreum
19
+ feature :encrypted_fields
20
+ identifier_field :id
21
+ field :id
22
+ field :email
23
+ encrypted_field :api_key, aad_fields: [:email]
24
+ end
25
+
26
+ # Model with multiple AAD fields to test positional stability
27
+ class AADMultiFieldModel < Familia::Horreum
28
+ feature :encrypted_fields
29
+ identifier_field :id
30
+ field :id
31
+ field :org
32
+ field :role
33
+ field :region
34
+ encrypted_field :token, aad_fields: [:org, :role, :region]
35
+ end
36
+
37
+ Familia.dbclient.flushdb
38
+
39
+ ## Encrypt with nil AAD field and reveal without changing it succeeds
40
+ record = AADNilFieldModel.new(id: 'nil-aad-1')
41
+ record.api_key = 'nil-aad-secret'
42
+ decrypted = nil
43
+ record.api_key.reveal { |pt| decrypted = pt }
44
+ decrypted
45
+ #=> "nil-aad-secret"
46
+
47
+ ## Encrypt with nil AAD field then set field to non-nil causes reveal to fail
48
+ record2 = AADNilFieldModel.new(id: 'nil-aad-2')
49
+ record2.api_key = 'bound-to-nil'
50
+ record2.email = 'now-set@example.com'
51
+ result = begin
52
+ record2.api_key.reveal { |pt| pt }
53
+ 'UNEXPECTED SUCCESS'
54
+ rescue Familia::EncryptionError
55
+ 'Familia::EncryptionError'
56
+ end
57
+ result
58
+ #=> "Familia::EncryptionError"
59
+
60
+ ## Encrypt with non-nil AAD field then set to nil causes reveal to fail
61
+ record3 = AADNilFieldModel.new(id: 'nil-aad-3', email: 'was-set@example.com')
62
+ record3.api_key = 'bound-to-email'
63
+ record3.email = nil
64
+ result3 = begin
65
+ record3.api_key.reveal { |pt| pt }
66
+ 'UNEXPECTED SUCCESS'
67
+ rescue Familia::EncryptionError
68
+ 'Familia::EncryptionError'
69
+ end
70
+ result3
71
+ #=> "Familia::EncryptionError"
72
+
73
+ ## Encrypt with empty string AAD field and reveal succeeds
74
+ # Note: the setter treats empty string as nil for the encrypted field itself,
75
+ # but the AAD field (email) is a regular field that can hold empty string.
76
+ record4 = AADNilFieldModel.new(id: 'nil-aad-4', email: '')
77
+ record4.api_key = 'empty-email-secret'
78
+ decrypted4 = nil
79
+ record4.api_key.reveal { |pt| decrypted4 = pt }
80
+ decrypted4
81
+ #=> "empty-email-secret"
82
+
83
+ ## nil and empty string AAD fields produce identical AAD (both are "no value")
84
+ record_nil = AADNilFieldModel.new(id: 'aad-cmp-1')
85
+ record_empty = AADNilFieldModel.new(id: 'aad-cmp-1', email: '')
86
+ field_type = AADNilFieldModel.field_types[:api_key]
87
+ aad_nil = field_type.send(:build_aad, record_nil)
88
+ aad_empty = field_type.send(:build_aad, record_empty)
89
+ aad_nil == aad_empty
90
+ #=> true
91
+
92
+ ## Nil AAD fields preserve position: [A, nil, C] differs from [A, C]
93
+ # With the old .compact, a nil middle field would shift later values left,
94
+ # producing the same hash as a model where that field never existed.
95
+ # With .to_s, nil becomes "" and holds its position: "A::C" != "A:C".
96
+ @ft = AADMultiFieldModel.field_types[:token]
97
+ rec_nil_middle = AADMultiFieldModel.new(id: 'pos-1', org: 'acme', role: nil, region: 'us')
98
+ rec_no_middle = AADMultiFieldModel.new(id: 'pos-1', org: 'acme', role: 'us')
99
+ aad_with_nil = @ft.send(:build_aad, rec_nil_middle)
100
+ aad_shifted = @ft.send(:build_aad, rec_no_middle)
101
+ aad_with_nil != aad_shifted
102
+ #=> true
103
+
104
+ ## Adjacent nil fields are distinguishable from a single nil
105
+ rec_two_nils = AADMultiFieldModel.new(id: 'pos-2', org: 'acme')
106
+ rec_one_nil = AADMultiFieldModel.new(id: 'pos-2', org: 'acme', region: 'eu')
107
+ aad_two = @ft.send(:build_aad, rec_two_nils)
108
+ aad_one = @ft.send(:build_aad, rec_one_nil)
109
+ aad_two != aad_one
110
+ #=> true
111
+
112
+ Familia.dbclient.flushdb
113
+ Familia.config.encryption_keys = nil
114
+ Familia.config.current_key_version = nil
@@ -28,16 +28,15 @@ Familia.dbclient.flushdb
28
28
 
29
29
  ## AAD prevents field substitution attacks - proper cross-record test
30
30
  @victim = AADProtectedModel.new(id: 'victim-1', email: 'victim@example.com')
31
- @victim.save # Need to save first for AAD to be active
32
31
  @victim.api_key = 'victim-secret-key'
33
- @victim.save # Save the encrypted value
32
+ @victim.save
34
33
 
35
34
  # Extract the raw encrypted JSON data (not the ConcealedString object)
36
35
  @victim_encrypted_data = @victim.api_key.encrypted_value
37
36
 
38
37
  # Create an attacker record with different AAD context (different email)
39
38
  @attacker = AADProtectedModel.new(id: 'attacker-1', email: 'attacker@evil.com')
40
- @attacker.save # Need to save for AAD to be active
39
+ @attacker.save
41
40
 
42
41
  # Simulate database tampering: set attacker's field to victim's encrypted data
43
42
  # This simulates what an attacker with database access might try to do
@@ -67,7 +66,6 @@ end
67
66
 
68
67
  ## Cross-record attack with same email (should still fail due to different identifiers)
69
68
  victim2 = AADProtectedModel.new(id: 'victim-2', email: 'shared@example.com')
70
- victim2.save
71
69
  victim2.api_key = 'victim2-secret'
72
70
  victim2.save
73
71
 
@@ -89,21 +87,24 @@ end
89
87
  @result3
90
88
  #=> "Familia::EncryptionError"
91
89
 
92
- ## Without saving, AAD is not enforced (no database context)
90
+ ## AAD fields are enforced on unsaved records - changing aad_field breaks reveal
93
91
  unsaved_model = AADProtectedModel.new(id: 'unsaved-1', email: 'test@example.com')
94
92
  unsaved_model.api_key = 'test-key'
95
93
 
96
- # Change email after encryption but before save - should still work
94
+ # Change email after encryption - AAD mismatch should cause decryption failure
97
95
  unsaved_model.email = 'changed@example.com'
98
- decrypted = nil
99
- unsaved_model.api_key.reveal { |plaintext| decrypted = plaintext }
100
- decrypted
101
- #=> "test-key"
96
+ @result_unsaved = begin
97
+ unsaved_model.api_key.reveal { |plaintext| plaintext }
98
+ "UNEXPECTED SUCCESS"
99
+ rescue Familia::EncryptionError => error
100
+ error.class.name
101
+ end
102
+ @result_unsaved
103
+ #=> "Familia::EncryptionError"
102
104
 
103
105
  ## Cross-model attack with raw encrypted JSON
104
106
  # Demonstrate that raw encrypted data can't be moved between models
105
107
  @json_victim = AADProtectedModel.new(id: 'json-victim-1', email: 'jsonvictim@example.com')
106
- @json_victim.save
107
108
  @json_victim.api_key = 'json-victim-secret'
108
109
 
109
110
  # Get the raw encrypted JSON and create a new ConcealedString for different record
@@ -126,7 +127,6 @@ end
126
127
 
127
128
  ## Successful decryption with correct context (control test)
128
129
  legitimate_user = AADProtectedModel.new(id: 'legitimate-1', email: 'legit@example.com')
129
- legitimate_user.save
130
130
  legitimate_user.api_key = 'legitimate-secret'
131
131
  legitimate_user.save
132
132
 
@@ -0,0 +1,102 @@
1
+ # try/features/encrypted_fields/aad_roundtrip_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ # Verifies that encrypted fields with aad_fields survive the
6
+ # create! -> reveal and create! -> load -> reveal round-trips.
7
+ # Regression test for GitHub issue #232.
8
+
9
+ require 'base64'
10
+ require_relative '../../support/helpers/test_helpers'
11
+
12
+ test_keys = {
13
+ v1: Base64.strict_encode64('a' * 32),
14
+ }
15
+ Familia.config.encryption_keys = test_keys
16
+ Familia.config.current_key_version = :v1
17
+
18
+ class AADRoundtripModel < Familia::Horreum
19
+ feature :encrypted_fields
20
+ identifier_field :domain_id
21
+ field :domain_id
22
+ field :email
23
+ encrypted_field :api_key, aad_fields: [:domain_id]
24
+ end
25
+
26
+ class AADRoundtripEnforcementModel < Familia::Horreum
27
+ feature :encrypted_fields
28
+ identifier_field :id
29
+ field :id
30
+ field :email
31
+ encrypted_field :api_key, aad_fields: [:email]
32
+ end
33
+
34
+ Familia.dbclient.flushdb
35
+
36
+ ## reveal succeeds on in-memory object after create! with aad_fields
37
+ config = AADRoundtripModel.create!(domain_id: 'dom-001', api_key: 'secret-abc')
38
+ decrypted = nil
39
+ config.api_key.reveal { |pt| decrypted = pt }
40
+ decrypted
41
+ #=> "secret-abc"
42
+
43
+ ## reveal succeeds on object reloaded from Redis after create!
44
+ config2 = AADRoundtripModel.create!(domain_id: 'dom-002', api_key: 'secret-xyz')
45
+ reloaded = AADRoundtripModel.load('dom-002')
46
+ decrypted2 = nil
47
+ reloaded.api_key.reveal { |pt| decrypted2 = pt }
48
+ decrypted2
49
+ #=> "secret-xyz"
50
+
51
+ ## build_aad produces identical AAD before and after save
52
+ unsaved = AADRoundtripModel.new(domain_id: 'dom-003', email: 'test@example.com')
53
+ field_type = AADRoundtripModel.field_types[:api_key]
54
+ aad_before = field_type.send(:build_aad, unsaved)
55
+ unsaved.save
56
+ aad_after = field_type.send(:build_aad, unsaved)
57
+ aad_before == aad_after
58
+ #=> true
59
+
60
+ ## round-trip works with new + manual save pattern
61
+ manual = AADRoundtripModel.new(domain_id: 'dom-004')
62
+ manual.api_key = 'manual-secret'
63
+ manual.save
64
+ decrypted3 = nil
65
+ manual.api_key.reveal { |pt| decrypted3 = pt }
66
+ decrypted3
67
+ #=> "manual-secret"
68
+
69
+ ## round-trip works with new + save + load pattern
70
+ manual2 = AADRoundtripModel.new(domain_id: 'dom-005')
71
+ manual2.api_key = 'reload-secret'
72
+ manual2.save
73
+ reloaded2 = AADRoundtripModel.load('dom-005')
74
+ decrypted4 = nil
75
+ reloaded2.api_key.reveal { |pt| decrypted4 = pt }
76
+ decrypted4
77
+ #=> "reload-secret"
78
+
79
+ ## AAD fields are enforced on unsaved records - unchanged field decrypts successfully
80
+ unsaved2 = AADRoundtripEnforcementModel.new(id: 'enforce-1', email: 'stable@example.com')
81
+ unsaved2.api_key = 'bound-secret'
82
+ decrypted5 = nil
83
+ unsaved2.api_key.reveal { |pt| decrypted5 = pt }
84
+ decrypted5
85
+ #=> "bound-secret"
86
+
87
+ ## AAD fields are enforced on unsaved records - changed field breaks reveal
88
+ unsaved3 = AADRoundtripEnforcementModel.new(id: 'enforce-2', email: 'original@example.com')
89
+ unsaved3.api_key = 'enforced-secret'
90
+ unsaved3.email = 'tampered@example.com'
91
+ result_enforced = begin
92
+ unsaved3.api_key.reveal { |pt| pt }
93
+ 'UNEXPECTED SUCCESS'
94
+ rescue Familia::EncryptionError
95
+ 'Familia::EncryptionError'
96
+ end
97
+ result_enforced
98
+ #=> "Familia::EncryptionError"
99
+
100
+ Familia.dbclient.flushdb
101
+ Familia.config.encryption_keys = nil
102
+ Familia.config.current_key_version = nil
@@ -0,0 +1,69 @@
1
+ # try/features/encrypted_fields/fast_writer_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ # Tests the fast writer path (model.field_name! 'value') for encrypted fields.
6
+ # Verifies immediate persistence, nil guard, and equivalence with normal setter.
7
+
8
+ require 'base64'
9
+ require_relative '../../support/helpers/test_helpers'
10
+
11
+ test_keys = {
12
+ v1: Base64.strict_encode64('a' * 32),
13
+ }
14
+ Familia.config.encryption_keys = test_keys
15
+ Familia.config.current_key_version = :v1
16
+
17
+ class EncryptedFastWriterModel < Familia::Horreum
18
+ feature :encrypted_fields
19
+ identifier_field :id
20
+ field :id
21
+ encrypted_field :secret
22
+ end
23
+
24
+ Familia.dbclient.flushdb
25
+
26
+ ## Fast write persists and can be revealed after reload
27
+ record = EncryptedFastWriterModel.new(id: 'fast-1')
28
+ record.save
29
+ record.secret! 'fast-value'
30
+ reloaded = EncryptedFastWriterModel.load('fast-1')
31
+ decrypted = nil
32
+ reloaded.secret.reveal { |pt| decrypted = pt }
33
+ decrypted
34
+ #=> "fast-value"
35
+
36
+ ## Fast write with nil raises ArgumentError
37
+ record2 = EncryptedFastWriterModel.new(id: 'fast-2')
38
+ record2.save
39
+ result = begin
40
+ record2.secret! nil
41
+ 'UNEXPECTED SUCCESS'
42
+ rescue ArgumentError => e
43
+ e.class.name
44
+ end
45
+ result
46
+ #=> "ArgumentError"
47
+
48
+ ## Fast write and normal setter produce equivalent decryptable results
49
+ record_fast = EncryptedFastWriterModel.new(id: 'fast-3')
50
+ record_fast.save
51
+ record_fast.secret! 'shared-value'
52
+
53
+ record_normal = EncryptedFastWriterModel.new(id: 'normal-3')
54
+ record_normal.secret = 'shared-value'
55
+ record_normal.save
56
+
57
+ loaded_fast = EncryptedFastWriterModel.load('fast-3')
58
+ loaded_normal = EncryptedFastWriterModel.load('normal-3')
59
+
60
+ decrypted_fast = nil
61
+ decrypted_normal = nil
62
+ loaded_fast.secret.reveal { |pt| decrypted_fast = pt }
63
+ loaded_normal.secret.reveal { |pt| decrypted_normal = pt }
64
+ decrypted_fast == decrypted_normal && decrypted_fast == 'shared-value'
65
+ #=> true
66
+
67
+ Familia.dbclient.flushdb
68
+ Familia.config.encryption_keys = nil
69
+ Familia.config.current_key_version = nil
@@ -72,16 +72,18 @@ data.algorithm
72
72
  ## from_json handles payloads missing the encoding key (backward compat)
73
73
  test_keys = { v1: Base64.strict_encode64('a' * 32) }
74
74
  context = "TestModel:secret_field:user123"
75
- plaintext = "backward compat test"
76
75
 
77
- Familia.config.encryption_keys = test_keys
78
- Familia.config.current_key_version = :v1
79
- encrypted = Familia::Encryption.encrypt(plaintext, context: context)
80
-
81
- # Verify the envelope does not contain encoding key
82
- parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
83
- parsed.key?(:encoding)
84
- #=> false
76
+ # Simulate a pre-Phase-2 envelope by constructing one without encoding
77
+ legacy_hash = {
78
+ algorithm: "xchacha20poly1305",
79
+ nonce: Base64.strict_encode64('n' * 24),
80
+ ciphertext: Base64.strict_encode64('c' * 32),
81
+ auth_tag: Base64.strict_encode64('t' * 16),
82
+ key_version: "v1"
83
+ }
84
+ data = Familia::Encryption::EncryptedData.from_json(legacy_hash)
85
+ data.encoding
86
+ #=> nil
85
87
 
86
88
  ## Decryption of legacy envelope (no encoding key) defaults to UTF-8
87
89
  test_keys = { v1: Base64.strict_encode64('a' * 32) }
@@ -214,7 +216,7 @@ json_with_extras = Familia::JsonSerializer.dump(parsed)
214
216
 
215
217
  data = Familia::Encryption::EncryptedData.validate!(json_with_extras)
216
218
  [data.class, data.encoding]
217
- #=> [Familia::Encryption::EncryptedData, nil]
219
+ #=> [Familia::Encryption::EncryptedData, "UTF-8"]
218
220
 
219
221
  ## from_json handles Hash with string keys and unknown extras
220
222
  test_keys = { v1: Base64.strict_encode64('a' * 32) }
@@ -233,17 +235,15 @@ data.algorithm
233
235
  #=> "xchacha20poly1305"
234
236
 
235
237
  ## from_json Hash path without encoding key defaults encoding to nil
236
- test_keys = { v1: Base64.strict_encode64('a' * 32) }
237
- context = "TestModel:secret_field:user123"
238
- plaintext = "hash backward compat"
239
-
240
- Familia.config.encryption_keys = test_keys
241
- Familia.config.current_key_version = :v1
242
- encrypted = Familia::Encryption.encrypt(plaintext, context: context)
243
-
244
- # Parse to hash, confirm no encoding, pass as hash
245
- parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
246
- data = Familia::Encryption::EncryptedData.from_json(parsed)
238
+ # Simulate a pre-Phase-2 envelope by constructing one without encoding
239
+ legacy_hash = {
240
+ algorithm: "xchacha20poly1305",
241
+ nonce: Base64.strict_encode64('n' * 24),
242
+ ciphertext: Base64.strict_encode64('c' * 32),
243
+ auth_tag: Base64.strict_encode64('t' * 16),
244
+ key_version: "v1"
245
+ }
246
+ data = Familia::Encryption::EncryptedData.from_json(legacy_hash)
247
247
  data.encoding
248
248
  #=> nil
249
249
 
@@ -0,0 +1,108 @@
1
+ # try/features/encryption/encoding_phase2_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ # Phase 2: Write encoding on encrypt -- the encrypt method now includes
6
+ # encoding: plaintext.encoding.name in the EncryptedData envelope.
7
+ # See: https://github.com/delano/familia/issues/229
8
+
9
+ require_relative '../../support/helpers/test_helpers'
10
+ require_relative '../../../lib/familia/encryption'
11
+ require 'base64'
12
+
13
+ ## Encrypted payload includes encoding key
14
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
15
+ context = "TestModel:secret_field:user123"
16
+ plaintext = "hello world"
17
+
18
+ Familia.config.encryption_keys = test_keys
19
+ Familia.config.current_key_version = :v1
20
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
21
+
22
+ parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
23
+ parsed.key?(:encoding)
24
+ #=> true
25
+
26
+ ## Encoding value matches plaintext encoding for UTF-8 string
27
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
28
+ context = "TestModel:secret_field:user123"
29
+ plaintext = "hello world"
30
+
31
+ Familia.config.encryption_keys = test_keys
32
+ Familia.config.current_key_version = :v1
33
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
34
+
35
+ parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
36
+ parsed[:encoding]
37
+ #=> "UTF-8"
38
+
39
+ ## ISO-8859-1 encoding captured correctly
40
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
41
+ context = "TestModel:secret_field:user123"
42
+ plaintext = "caf\u00e9".encode("ISO-8859-1")
43
+
44
+ Familia.config.encryption_keys = test_keys
45
+ Familia.config.current_key_version = :v1
46
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
47
+
48
+ parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
49
+ parsed[:encoding]
50
+ #=> "ISO-8859-1"
51
+
52
+ ## ASCII-8BIT (binary) encoding captured
53
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
54
+ context = "TestModel:secret_field:user123"
55
+ plaintext = "binary\x00\xFF".b
56
+
57
+ Familia.config.encryption_keys = test_keys
58
+ Familia.config.current_key_version = :v1
59
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
60
+
61
+ parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
62
+ parsed[:encoding]
63
+ #=> "ASCII-8BIT"
64
+
65
+ ## Round-trip preserves UTF-8 encoding
66
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
67
+ context = "TestModel:secret_field:user123"
68
+ plaintext = "caf\u00e9 na\u00efve r\u00e9sum\u00e9"
69
+
70
+ Familia.config.encryption_keys = test_keys
71
+ Familia.config.current_key_version = :v1
72
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
73
+ decrypted = Familia::Encryption.decrypt(encrypted, context: context)
74
+ [decrypted == plaintext, decrypted.encoding.to_s]
75
+ #=> [true, "UTF-8"]
76
+
77
+ ## Round-trip preserves non-UTF-8 encoding
78
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
79
+ context = "TestModel:secret_field:user123"
80
+ plaintext = "caf\u00e9".encode("ISO-8859-1")
81
+
82
+ Familia.config.encryption_keys = test_keys
83
+ Familia.config.current_key_version = :v1
84
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
85
+ decrypted = Familia::Encryption.decrypt(encrypted, context: context)
86
+ [decrypted.encoding.to_s, decrypted.bytes == plaintext.bytes]
87
+ #=> ["ISO-8859-1", true]
88
+
89
+ ## Legacy envelopes without encoding still decrypt to UTF-8
90
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
91
+ context = "TestModel:secret_field:user123"
92
+ plaintext = "legacy payload"
93
+
94
+ Familia.config.encryption_keys = test_keys
95
+ Familia.config.current_key_version = :v1
96
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
97
+
98
+ # Strip the encoding key to simulate a Phase 1 (or older) envelope
99
+ parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
100
+ parsed.delete(:encoding)
101
+ legacy_json = Familia::JsonSerializer.dump(parsed)
102
+
103
+ decrypted = Familia::Encryption.decrypt(legacy_json, context: context)
104
+ [decrypted, decrypted.encoding.to_s]
105
+ #=> ["legacy payload", "UTF-8"]
106
+
107
+ # TEARDOWN
108
+ Fiber[:familia_key_cache]&.clear if Fiber[:familia_key_cache]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: familia
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.2
4
+ version: 2.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -364,7 +364,9 @@ files:
364
364
  - try/features/count_any_edge_cases_try.rb
365
365
  - try/features/count_any_methods_try.rb
366
366
  - try/features/dirty_tracking_try.rb
367
+ - try/features/encrypted_fields/aad_nil_fields_try.rb
367
368
  - try/features/encrypted_fields/aad_protection_try.rb
369
+ - try/features/encrypted_fields/aad_roundtrip_try.rb
368
370
  - try/features/encrypted_fields/concealed_string_core_try.rb
369
371
  - try/features/encrypted_fields/context_isolation_try.rb
370
372
  - try/features/encrypted_fields/encrypted_fields_core_try.rb
@@ -372,6 +374,7 @@ files:
372
374
  - try/features/encrypted_fields/encrypted_fields_no_cache_security_try.rb
373
375
  - try/features/encrypted_fields/encrypted_fields_security_try.rb
374
376
  - try/features/encrypted_fields/error_conditions_try.rb
377
+ - try/features/encrypted_fields/fast_writer_try.rb
375
378
  - try/features/encrypted_fields/fresh_key_derivation_try.rb
376
379
  - try/features/encrypted_fields/fresh_key_try.rb
377
380
  - try/features/encrypted_fields/key_rotation_try.rb
@@ -384,6 +387,7 @@ files:
384
387
  - try/features/encryption/config_persistence_try.rb
385
388
  - try/features/encryption/core_try.rb
386
389
  - try/features/encryption/encoding_phase1_try.rb
390
+ - try/features/encryption/encoding_phase2_try.rb
387
391
  - try/features/encryption/instance_variable_scope_try.rb
388
392
  - try/features/encryption/module_loading_try.rb
389
393
  - try/features/encryption/providers/aes_gcm_provider_try.rb