familia 2.3.1 → 2.3.2

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: 87748023c0bec120fe0b4936eac184ebe7f72651639b7ff87238782987654cc7
4
- data.tar.gz: 5a3cbaf9000cf1e12cdcff9f2d35eecca57a010725e501de37485bda7cf214af
3
+ metadata.gz: cfb3f3aa06c0b96493a86fbe2e3c475e81b9a84d69d7b88e06025adbb597ad8e
4
+ data.tar.gz: c567f15b73c06a67baad7b1aff36e23d0d544de1a25308716f6edbac235a3ca9
5
5
  SHA512:
6
- metadata.gz: 21847a2154326c84d70557ee62ecfc4ea960f74ae6358e41b6c1d6fb7012d58034256a35dbc9aae26778a179cc3524e54012933a84191d2dc570f9d73c400cb8
7
- data.tar.gz: da077f52d0ecbeba97779470f1e6d3a51934e7e7e3e03b05005e1187794f3ef23c9386aa15fe3747113c31982bf6e56add41948bd78a0d6d2867abaef883a31b
6
+ metadata.gz: 66028adaf4e3004b57bb17b39a99745ad771b2af915253cce02a6ca130e028cdf39e0fc11d90c40729ffa85cc5b5fa8c2c2f1b0320a7c3e21ea74858d173c6b6
7
+ data.tar.gz: aa3c991ea06b0c2268034784b83fb2dfb5ab6507902de3bca2345149dba3bdb287829769abbb15350c093551c79419c3e2e79558cacac2e589161ebdadd9dd4f
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,35 @@ 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.2:
11
+
12
+ 2.3.2 — 2026-03-12
13
+ ==================
14
+
15
+ Fixed
16
+ -----
17
+
18
+ - ``Manager#decrypt`` no longer returns ASCII-8BIT strings. Decrypted plaintext
19
+ is now force-encoded to UTF-8 by default, fixing compatibility with json 2.18+
20
+ (which rejects non-UTF-8 strings) and preventing a hard error in json 3.0.
21
+ When an ``encoding`` field is present in the encrypted envelope, that encoding
22
+ is used instead. Fixes `#228 <https://github.com/delano/familia/issues/228>`_.
23
+
24
+ - ``EncryptedData.from_json`` and ``validate!`` now filter unknown keys from
25
+ parsed envelopes before instantiation. This prevents ``ArgumentError`` when
26
+ reading envelopes written by future versions that include additional fields
27
+ (e.g. ``encoding``, ``compression``).
28
+
29
+ AI Assistance
30
+ -------------
31
+
32
+ - Claude implemented the Phase 1 defensive read strategy, added the ``encoding``
33
+ field to ``EncryptedData`` with nil default and ``to_h.compact`` for clean
34
+ serialization, and wrote 22 test cases covering encoding round-trips, legacy
35
+ envelope backward compatibility, unknown key filtering, and edge cases (nil
36
+ input, bogus encoding names, binary ASCII-8BIT content).
37
+ PR `#230 <https://github.com/delano/familia/pull/230>`_.
38
+
10
39
  .. _changelog-2.3.1:
11
40
 
12
41
  2.3.1 — 2026-03-06
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (2.3.1)
4
+ familia (2.3.2)
5
5
  concurrent-ruby (~> 1.3)
6
6
  connection_pool (>= 2.4, < 4.0)
7
7
  csv (~> 3.3)
@@ -4,7 +4,18 @@
4
4
 
5
5
  module Familia
6
6
  module Encryption
7
- EncryptedData = Data.define(:algorithm, :nonce, :ciphertext, :auth_tag, :key_version) do
7
+ EncryptedData = Data.define(:algorithm, :nonce, :ciphertext, :auth_tag, :key_version, :encoding) do
8
+ def initialize(algorithm:, nonce:, ciphertext:, auth_tag:, key_version:, encoding: nil)
9
+ super
10
+ end
11
+
12
+ # Omit nil-valued keys from the hash representation so that
13
+ # the encrypted envelope stays backward-compatible (no :encoding
14
+ # key unless explicitly set).
15
+ def to_h
16
+ super.compact
17
+ end
18
+
8
19
  # Class methods for parsing and validation
9
20
  def self.valid?(json_string)
10
21
  return true if json_string.nil? # Allow nil values
@@ -43,7 +54,7 @@ module Familia
43
54
 
44
55
  raise EncryptionError, "Missing required fields: #{missing_fields.join(', ')}" unless missing_fields.empty?
45
56
 
46
- new(**parsed)
57
+ new(**parsed.slice(*members))
47
58
  end
48
59
 
49
60
  def self.from_json(json_string_or_hash)
@@ -53,7 +64,7 @@ module Familia
53
64
  parsed = json_string_or_hash
54
65
  # Symbolize keys if they're strings
55
66
  parsed = parsed.transform_keys(&:to_sym) if parsed.keys.first.is_a?(String)
56
- new(**parsed)
67
+ new(**parsed.slice(*members))
57
68
  else
58
69
  # JSON string - validate and parse
59
70
  validate!(json_string_or_hash)
@@ -60,7 +60,8 @@ module Familia
60
60
  ciphertext = decode_and_validate_ciphertext(data.ciphertext)
61
61
  auth_tag = decode_and_validate(data.auth_tag, provider.auth_tag_size, 'auth_tag')
62
62
 
63
- provider.decrypt(ciphertext, key, nonce, auth_tag, additional_data)
63
+ plaintext = provider.decrypt(ciphertext, key, nonce, auth_tag, additional_data)
64
+ plaintext.force_encoding(data.encoding || 'UTF-8')
64
65
  rescue EncryptionError
65
66
  raise
66
67
  rescue Familia::SerializerError => e
@@ -4,5 +4,5 @@
4
4
 
5
5
  module Familia
6
6
  # Version information for the Familia
7
- VERSION = '2.3.1'
7
+ VERSION = '2.3.2'
8
8
  end
@@ -0,0 +1,357 @@
1
+ # try/features/encryption/encoding_phase1_try.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ # Phase 1: Defensive read -- filter unknown keys, default encoding to UTF-8 on decrypt
6
+ # See: https://github.com/delano/familia/issues/228
7
+
8
+ require_relative '../../support/helpers/test_helpers'
9
+ require_relative '../../../lib/familia/encryption'
10
+ require 'base64'
11
+
12
+ ## Decrypted value has UTF-8 encoding (not ASCII-8BIT)
13
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
14
+ context = "TestModel:secret_field:user123"
15
+ plaintext = "hello world"
16
+
17
+ Familia.config.encryption_keys = test_keys
18
+ Familia.config.current_key_version = :v1
19
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
20
+ decrypted = Familia::Encryption.decrypt(encrypted, context: context)
21
+ decrypted.encoding.to_s
22
+ #=> "UTF-8"
23
+
24
+ ## Round-trip preserves encoding through encrypt then decrypt
25
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
26
+ context = "TestModel:secret_field:user123"
27
+ plaintext = "caf\u00e9 na\u00efve r\u00e9sum\u00e9"
28
+
29
+ Familia.config.encryption_keys = test_keys
30
+ Familia.config.current_key_version = :v1
31
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
32
+ decrypted = Familia::Encryption.decrypt(encrypted, context: context)
33
+ [decrypted, decrypted.encoding.to_s]
34
+ #=> ["caf\u00e9 na\u00efve r\u00e9sum\u00e9", "UTF-8"]
35
+
36
+ ## from_json handles payloads with extra unknown keys without raising
37
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
38
+ context = "TestModel:secret_field:user123"
39
+ plaintext = "unknown keys test"
40
+
41
+ Familia.config.encryption_keys = test_keys
42
+ Familia.config.current_key_version = :v1
43
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
44
+
45
+ # Parse, inject unknown keys, re-serialize
46
+ parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
47
+ parsed[:future_field] = "something new"
48
+ parsed[:version] = 99
49
+ json_with_extras = Familia::JsonSerializer.dump(parsed)
50
+
51
+ # Should decrypt without error, ignoring unknown keys
52
+ decrypted = Familia::Encryption.decrypt(json_with_extras, context: context)
53
+ decrypted
54
+ #=> "unknown keys test"
55
+
56
+ ## from_json handles Hash input with extra unknown keys
57
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
58
+ context = "TestModel:secret_field:user123"
59
+ plaintext = "hash extra keys"
60
+
61
+ Familia.config.encryption_keys = test_keys
62
+ Familia.config.current_key_version = :v1
63
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
64
+
65
+ # Parse into hash, add unknown keys, pass hash directly
66
+ parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
67
+ parsed[:unknown_extra] = "ignored"
68
+ data = Familia::Encryption::EncryptedData.from_json(parsed)
69
+ data.algorithm
70
+ #=> "xchacha20poly1305"
71
+
72
+ ## from_json handles payloads missing the encoding key (backward compat)
73
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
74
+ context = "TestModel:secret_field:user123"
75
+ plaintext = "backward compat test"
76
+
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
85
+
86
+ ## Decryption of legacy envelope (no encoding key) defaults to UTF-8
87
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
88
+ context = "TestModel:secret_field:user123"
89
+ plaintext = "legacy payload"
90
+
91
+ Familia.config.encryption_keys = test_keys
92
+ Familia.config.current_key_version = :v1
93
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
94
+
95
+ # Simulate a legacy envelope that definitely has no encoding key
96
+ parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
97
+ parsed.delete(:encoding)
98
+ legacy_json = Familia::JsonSerializer.dump(parsed)
99
+
100
+ decrypted = Familia::Encryption.decrypt(legacy_json, context: context)
101
+ [decrypted, decrypted.encoding.to_s]
102
+ #=> ["legacy payload", "UTF-8"]
103
+
104
+ ## EncryptedData encoding field defaults to nil when not provided
105
+ data = Familia::Encryption::EncryptedData.new(
106
+ algorithm: "test",
107
+ nonce: "nonce",
108
+ ciphertext: "ct",
109
+ auth_tag: "tag",
110
+ key_version: "v1"
111
+ )
112
+ data.encoding
113
+ #=> nil
114
+
115
+ ## EncryptedData accepts encoding when provided
116
+ data = Familia::Encryption::EncryptedData.new(
117
+ algorithm: "test",
118
+ nonce: "nonce",
119
+ ciphertext: "ct",
120
+ auth_tag: "tag",
121
+ key_version: "v1",
122
+ encoding: "ISO-8859-1"
123
+ )
124
+ data.encoding
125
+ #=> "ISO-8859-1"
126
+
127
+ ## to_h omits encoding when nil (envelope stays clean)
128
+ data = Familia::Encryption::EncryptedData.new(
129
+ algorithm: "test",
130
+ nonce: "nonce",
131
+ ciphertext: "ct",
132
+ auth_tag: "tag",
133
+ key_version: "v1"
134
+ )
135
+ data.to_h.key?(:encoding)
136
+ #=> false
137
+
138
+ ## to_h includes encoding when explicitly set
139
+ data = Familia::Encryption::EncryptedData.new(
140
+ algorithm: "test",
141
+ nonce: "nonce",
142
+ ciphertext: "ct",
143
+ auth_tag: "tag",
144
+ key_version: "v1",
145
+ encoding: "UTF-8"
146
+ )
147
+ data.to_h.key?(:encoding)
148
+ #=> true
149
+
150
+ ## to_h compact output serializes to JSON without encoding null
151
+ data = Familia::Encryption::EncryptedData.new(
152
+ algorithm: "test",
153
+ nonce: "nonce",
154
+ ciphertext: "ct",
155
+ auth_tag: "tag",
156
+ key_version: "v1"
157
+ )
158
+ json = Familia::JsonSerializer.dump(data.to_h)
159
+ json.include?('"encoding"')
160
+ #=> false
161
+
162
+ ## Future envelope with encoding key present decrypts with specified encoding
163
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
164
+ context = "TestModel:secret_field:user123"
165
+ plaintext = "future format"
166
+
167
+ Familia.config.encryption_keys = test_keys
168
+ Familia.config.current_key_version = :v1
169
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
170
+
171
+ # Simulate a future writer that includes encoding in the envelope
172
+ parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
173
+ parsed[:encoding] = "UTF-8"
174
+ future_json = Familia::JsonSerializer.dump(parsed)
175
+
176
+ decrypted = Familia::Encryption.decrypt(future_json, context: context)
177
+ [decrypted, decrypted.encoding.to_s]
178
+ #=> ["future format", "UTF-8"]
179
+
180
+ ## Future envelope with encoding and extra unknown keys decrypts correctly
181
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
182
+ context = "TestModel:secret_field:user123"
183
+ plaintext = "full future envelope"
184
+
185
+ Familia.config.encryption_keys = test_keys
186
+ Familia.config.current_key_version = :v1
187
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
188
+
189
+ # Simulate a future writer with encoding, compression, and version fields
190
+ parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
191
+ parsed[:encoding] = "UTF-8"
192
+ parsed[:compression] = "zstd"
193
+ parsed[:envelope_version] = 2
194
+ future_json = Familia::JsonSerializer.dump(parsed)
195
+
196
+ decrypted = Familia::Encryption.decrypt(future_json, context: context)
197
+ decrypted
198
+ #=> "full future envelope"
199
+
200
+ ## validate! filters unknown keys from JSON string input
201
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
202
+ context = "TestModel:secret_field:user123"
203
+ plaintext = "validate filter test"
204
+
205
+ Familia.config.encryption_keys = test_keys
206
+ Familia.config.current_key_version = :v1
207
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
208
+
209
+ # Add unknown keys, run through validate! directly
210
+ parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
211
+ parsed[:compression] = "gzip"
212
+ parsed[:metadata] = { created_at: "2026-01-01" }
213
+ json_with_extras = Familia::JsonSerializer.dump(parsed)
214
+
215
+ data = Familia::Encryption::EncryptedData.validate!(json_with_extras)
216
+ [data.class, data.encoding]
217
+ #=> [Familia::Encryption::EncryptedData, nil]
218
+
219
+ ## from_json handles Hash with string keys and unknown extras
220
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
221
+ context = "TestModel:secret_field:user123"
222
+ plaintext = "string keys test"
223
+
224
+ Familia.config.encryption_keys = test_keys
225
+ Familia.config.current_key_version = :v1
226
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
227
+
228
+ # Build a string-keyed hash with extra keys (simulating external deserialization)
229
+ parsed = Familia::JsonSerializer.parse(encrypted) # no symbolize_names
230
+ parsed["unknown_key"] = "should be ignored"
231
+ data = Familia::Encryption::EncryptedData.from_json(parsed)
232
+ data.algorithm
233
+ #=> "xchacha20poly1305"
234
+
235
+ ## 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)
247
+ data.encoding
248
+ #=> nil
249
+
250
+ ## valid? returns true for envelopes with extra unknown keys
251
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
252
+ context = "TestModel:secret_field:user123"
253
+ plaintext = "valid check"
254
+
255
+ Familia.config.encryption_keys = test_keys
256
+ Familia.config.current_key_version = :v1
257
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
258
+
259
+ # Add extra keys to the JSON
260
+ parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
261
+ parsed[:future_field] = "extra"
262
+ parsed[:encoding] = "UTF-8"
263
+ json_with_extras = Familia::JsonSerializer.dump(parsed)
264
+
265
+ Familia::Encryption::EncryptedData.valid?(json_with_extras)
266
+ #=> true
267
+
268
+ ## Decrypt of empty string returns nil without error
269
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
270
+ context = "TestModel:secret_field:user123"
271
+
272
+ Familia.config.encryption_keys = test_keys
273
+ Familia.config.current_key_version = :v1
274
+ Familia::Encryption.decrypt("", context: context)
275
+ #=> nil
276
+
277
+ ## Non-UTF-8 encoding in envelope is applied on decrypt
278
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
279
+ context = "TestModel:secret_field:user123"
280
+ plaintext = "caf\u00e9"
281
+
282
+ Familia.config.encryption_keys = test_keys
283
+ Familia.config.current_key_version = :v1
284
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
285
+
286
+ # Simulate a Phase 2 writer that records the original encoding
287
+ parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
288
+ parsed[:encoding] = "ISO-8859-1"
289
+ future_json = Familia::JsonSerializer.dump(parsed)
290
+
291
+ decrypted = Familia::Encryption.decrypt(future_json, context: context)
292
+ [decrypted.encoding.to_s, decrypted.bytes == plaintext.bytes]
293
+ #=> ["ISO-8859-1", true]
294
+
295
+ ## Decrypt of nil returns nil without error
296
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
297
+ context = "TestModel:secret_field:user123"
298
+
299
+ Familia.config.encryption_keys = test_keys
300
+ Familia.config.current_key_version = :v1
301
+ Familia::Encryption.decrypt(nil, context: context)
302
+ #=> nil
303
+
304
+ ## Bogus encoding name in envelope raises EncryptionError
305
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
306
+ context = "TestModel:secret_field:user123"
307
+ plaintext = "bad encoding test"
308
+
309
+ Familia.config.encryption_keys = test_keys
310
+ Familia.config.current_key_version = :v1
311
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
312
+
313
+ # Inject an invalid encoding name into the envelope
314
+ parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
315
+ parsed[:encoding] = "NOT-A-REAL-ENCODING"
316
+ tampered_json = Familia::JsonSerializer.dump(parsed)
317
+
318
+ begin
319
+ Familia::Encryption.decrypt(tampered_json, context: context)
320
+ "should have raised"
321
+ rescue Familia::Encryption::EncryptionError => e
322
+ e.message
323
+ end
324
+ #=~ /Decryption failed/
325
+
326
+ ## Binary data round-trip preserves bytes when encoding is set to ASCII-8BIT
327
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
328
+ context = "TestModel:secret_field:user123"
329
+ plaintext = "binary\x00payload\xFF".b
330
+
331
+ Familia.config.encryption_keys = test_keys
332
+ Familia.config.current_key_version = :v1
333
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
334
+
335
+ # Simulate a Phase 2 writer that records ASCII-8BIT for binary content
336
+ parsed = Familia::JsonSerializer.parse(encrypted, symbolize_names: true)
337
+ parsed[:encoding] = "ASCII-8BIT"
338
+ future_json = Familia::JsonSerializer.dump(parsed)
339
+
340
+ decrypted = Familia::Encryption.decrypt(future_json, context: context)
341
+ [decrypted.encoding.to_s, decrypted.bytes == plaintext.bytes]
342
+ #=> ["ASCII-8BIT", true]
343
+
344
+ ## Multibyte UTF-8 content round-trips with correct encoding and byte count
345
+ test_keys = { v1: Base64.strict_encode64('a' * 32) }
346
+ context = "TestModel:secret_field:user123"
347
+ plaintext = "\u{1F600}\u{1F389}\u{2764}"
348
+
349
+ Familia.config.encryption_keys = test_keys
350
+ Familia.config.current_key_version = :v1
351
+ encrypted = Familia::Encryption.encrypt(plaintext, context: context)
352
+ decrypted = Familia::Encryption.decrypt(encrypted, context: context)
353
+ [decrypted == plaintext, decrypted.encoding.to_s, decrypted.valid_encoding?]
354
+ #=> [true, "UTF-8", true]
355
+
356
+ # TEARDOWN
357
+ 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.1
4
+ version: 2.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -383,6 +383,7 @@ files:
383
383
  - try/features/encrypted_fields/universal_serialization_safety_try.rb
384
384
  - try/features/encryption/config_persistence_try.rb
385
385
  - try/features/encryption/core_try.rb
386
+ - try/features/encryption/encoding_phase1_try.rb
386
387
  - try/features/encryption/instance_variable_scope_try.rb
387
388
  - try/features/encryption/module_loading_try.rb
388
389
  - try/features/encryption/providers/aes_gcm_provider_try.rb