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 +4 -4
- data/CHANGELOG.rst +41 -0
- data/Gemfile.lock +1 -1
- data/docs/guides/feature-relationships-indexing.md +39 -28
- data/lib/familia/encryption/manager.rb +7 -3
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +11 -9
- data/lib/familia/horreum/persistence.rb +5 -6
- data/lib/familia/version.rb +1 -1
- data/try/features/encrypted_fields/aad_nil_fields_try.rb +114 -0
- data/try/features/encrypted_fields/aad_protection_try.rb +12 -12
- data/try/features/encrypted_fields/aad_roundtrip_try.rb +102 -0
- data/try/features/encrypted_fields/fast_writer_try.rb +69 -0
- data/try/features/encryption/encoding_phase1_try.rb +21 -21
- data/try/features/encryption/encoding_phase2_try.rb +108 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 715c43e8b2b403b23fdb6ffbb33c8558a8dbccb6c9cc9dc80a5892a193cdf8c6
|
|
4
|
+
data.tar.gz: 89a9fafdd21877562178d8b4b103f7fe119b6ee12d50ebec9397691bffbf3094
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|
|
16
|
-
|
|
17
|
-
| `unique_index` | Class
|
|
18
|
-
| `unique_index` | Instance
|
|
19
|
-
| `multi_index`
|
|
20
|
-
| `multi_index`
|
|
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
|
|
51
|
-
|
|
52
|
-
| `User.find_by_email(email)`
|
|
53
|
-
| `User.index_email_for(user)`
|
|
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
|
|
143
|
-
|
|
144
|
-
| `Customer.role_index_for(value)`
|
|
145
|
-
| `Customer.find_all_by_role(value)`
|
|
146
|
-
| `Customer.sample_from_role(value, count)` | Random sample of objects
|
|
147
|
-
| `Customer.rebuild_role_index`
|
|
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
|
|
152
|
-
|
|
153
|
-
| `customer.add_to_class_role_index`
|
|
154
|
-
| `customer.remove_from_class_role_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
|
|
376
|
-
|
|
377
|
-
| Class unique
|
|
378
|
-
| Class multi
|
|
379
|
-
| Instance unique | `{scope}:{id}:{index_name}`
|
|
380
|
-
| Instance multi
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
233
|
-
|
|
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
|
|
279
|
-
#
|
|
280
|
-
#
|
|
281
|
-
#
|
|
282
|
-
#
|
|
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.
|
data/lib/familia/version.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
##
|
|
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
|
|
94
|
+
# Change email after encryption - AAD mismatch should cause decryption failure
|
|
97
95
|
unsaved_model.email = 'changed@example.com'
|
|
98
|
-
|
|
99
|
-
unsaved_model.api_key.reveal { |plaintext|
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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,
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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.
|
|
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
|