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 +4 -4
- data/CHANGELOG.rst +29 -0
- data/Gemfile.lock +1 -1
- data/lib/familia/encryption/encrypted_data.rb +14 -3
- data/lib/familia/encryption/manager.rb +2 -1
- data/lib/familia/version.rb +1 -1
- data/try/features/encryption/encoding_phase1_try.rb +357 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cfb3f3aa06c0b96493a86fbe2e3c475e81b9a84d69d7b88e06025adbb597ad8e
|
|
4
|
+
data.tar.gz: c567f15b73c06a67baad7b1aff36e23d0d544de1a25308716f6edbac235a3ca9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|
data/lib/familia/version.rb
CHANGED
|
@@ -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.
|
|
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
|