familia 2.10.1 → 2.11.0

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -4
  3. data/.github/workflows/release-gem.yml +161 -0
  4. data/.rubocop.yml +3 -1
  5. data/CHANGELOG.rst +91 -0
  6. data/Gemfile +6 -0
  7. data/Gemfile.lock +4 -3
  8. data/README.md +26 -0
  9. data/Rakefile +19 -0
  10. data/docs/guides/feature-encrypted-fields.md +4 -1
  11. data/docs/reference/api-technical.md +2 -1
  12. data/lib/familia/connection.rb +1 -1
  13. data/lib/familia/core_ext/securerandom.rb +57 -0
  14. data/lib/familia/data_type/types/sorted_set.rb +4 -2
  15. data/lib/familia/encryption/manager.rb +47 -8
  16. data/lib/familia/encryption/providers/aes_gcm_provider.rb +62 -2
  17. data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +5 -0
  18. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +6 -0
  19. data/lib/familia/encryption/request_cache.rb +18 -1
  20. data/lib/familia/features/encrypted_fields.rb +4 -1
  21. data/lib/familia/features/external_identifier.rb +50 -19
  22. data/lib/familia/features/relationships/participation/target_methods.rb +10 -7
  23. data/lib/familia/features/relationships/participation_membership.rb +12 -5
  24. data/lib/familia/horreum/management.rb +13 -0
  25. data/lib/familia/horreum/persistence.rb +58 -16
  26. data/lib/familia/migration/script.rb +12 -1
  27. data/lib/familia/secure_identifier.rb +32 -21
  28. data/lib/familia/settings.rb +66 -8
  29. data/lib/familia/verifiable_identifier.rb +31 -7
  30. data/lib/familia/version.rb +1 -1
  31. data/try/bug_fixes/class_destroy_index_cleanup_try.rb +50 -0
  32. data/try/bug_fixes/permission_query_try.rb +64 -0
  33. data/try/bug_fixes/sorted_set_members_count_try.rb +40 -0
  34. data/try/bug_fixes/stale_unique_index_try.rb +54 -0
  35. data/try/features/encryption/aes_gcm_salt_rotation_try.rb +219 -0
  36. data/try/features/encryption/config_persistence_try.rb +35 -0
  37. data/try/features/encryption/request_cache_try.rb +15 -0
  38. data/try/features/external_identifier/external_identifier_try.rb +73 -0
  39. data/try/features/relationships/participation_membership_security_try.rb +64 -0
  40. data/try/integration/verifiable_identifier_try.rb +24 -3
  41. data/try/support/stress/atomic_write_ownership_stress.rb +152 -0
  42. data/try/thread_safety/atomic_write_ownership_race_try.rb +107 -104
  43. data/try/unit/core/securerandom_polyfill_try.rb +61 -0
  44. data/try/unit/horreum/auto_indexing_on_save_try.rb +9 -8
  45. metadata +12 -2
  46. data/changelog.d/20260605_220911_anthropic_sleepy-allen.rst +0 -35
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7903e14486c85385ad0b682c009b1f7d182e0cff24b107aa0264692529bb8dfd
4
- data.tar.gz: 445219dfcd2df1cf2054b07d90e33902fc21532279730bc274629a2082a02f7f
3
+ metadata.gz: c322fb98599ca1596829b047f78d414a3216a4e94a61ded4c6d3a4355dd7780a
4
+ data.tar.gz: a0af405d479ba7547b2fe65fe17e7a9b0fc5fb04cfe8b5563ce10726187d0396
5
5
  SHA512:
6
- metadata.gz: 635d35b86d7c6a85332517e3b238b8b932d4823b0cfad2f58b7c349a530de44ae02b91930c9ddac7ed538c619cea3a6f8355eeaba20ebf52358ebd45f4547067
7
- data.tar.gz: d96538d6fbb486fcd92bc329acd52cba07008e40db5df059f338e060cb6dbb4789c8fe0e8e52ee69891662b1b2353eec3832032aa3ccda50848a986d06ea6a4d
6
+ metadata.gz: ac3aac1967a9730670b6f43f464c001d6c0acad7e599e528d94edb5b60b7939fb59dc5ef320962bfaacd1c204603e36d09647670f2511320a98c9b4fc9f0d715
7
+ data.tar.gz: f5f7e14e48be039962af4bcda0ac6c61248143353f773239c5b4ac0a9e9af498f64618d2f18dc48f903c7349d47e375f6e87c6ed686d7e0bbc6ebb444a4456b7
@@ -23,11 +23,8 @@ jobs:
23
23
  strategy:
24
24
  fail-fast: false
25
25
  matrix:
26
- ruby: ["3.4", "3.5"]
26
+ ruby: ["3.2", "3.3", "3.4", "3.5", "4.0"]
27
27
  continue-on-error: [false]
28
- include:
29
- - ruby: "4.0"
30
- continue-on-error: true
31
28
 
32
29
  services:
33
30
  redis:
@@ -0,0 +1,161 @@
1
+ # Release Gem Workflow
2
+ #
3
+ # Automatically builds and publishes the familia gem to RubyGems.org when a
4
+ # GitHub release is published with a semantic version tag (vMAJOR.MINOR.PATCH,
5
+ # e.g. v2.10.1, v3.0.0).
6
+ #
7
+ # Follows the canonical RubyGems Trusted Publishing workflow:
8
+ # https://guides.rubygems.org/trusted-publishing/
9
+ #
10
+ # ============================================================================
11
+ # SETUP INSTRUCTIONS (one-time)
12
+ # ============================================================================
13
+ #
14
+ # This workflow uses RubyGems Trusted Publishing (OIDC). No long-lived API
15
+ # key needs to be stored in GitHub.
16
+ #
17
+ # ----------------------------------------------------------------------------
18
+ # 1. Configure the trusted publisher on RubyGems.org
19
+ # ----------------------------------------------------------------------------
20
+ #
21
+ # a. Sign in to https://rubygems.org and enable MFA on your account
22
+ # (required for trusted publishing).
23
+ #
24
+ # b. Open the gem's trusted publisher page (works for both existing gems
25
+ # and the first-ever publish):
26
+ # Existing gem: https://rubygems.org/gems/familia/trusted_publishers
27
+ # First publish: https://rubygems.org/profile/oidc/pending_trusted_publishers/new
28
+ #
29
+ # c. Click "Create" and fill in:
30
+ # Publisher type: GitHub Actions
31
+ # Repository owner: delano
32
+ # Repository name: familia
33
+ # Workflow filename: release-gem.yml
34
+ # Environment: rubygems.org (must match `environment.name` below)
35
+ #
36
+ # d. Save. RubyGems will now accept short-lived OIDC tokens issued by this
37
+ # workflow running in this repository.
38
+ #
39
+ # ----------------------------------------------------------------------------
40
+ # 2. Configure the GitHub environment
41
+ # ----------------------------------------------------------------------------
42
+ #
43
+ # a. In GitHub: Settings -> Environments -> New environment
44
+ # Name: `rubygems.org` (must match `environment.name` below)
45
+ #
46
+ # b. Under "Deployment branches and tags", restrict to tags matching:
47
+ # v*.*.*
48
+ # This prevents the environment (and its OIDC token) from being used
49
+ # from any branch or non-release tag.
50
+ #
51
+ # c. Optional but recommended: add required reviewers to gate publishes
52
+ # behind manual approval.
53
+ #
54
+ # No GitHub secrets are required - OIDC handles authentication.
55
+ #
56
+ # ----------------------------------------------------------------------------
57
+ # 3. Cutting a release
58
+ # ----------------------------------------------------------------------------
59
+ #
60
+ # a. Bump Familia::VERSION in lib/familia/version.rb (semver: MAJOR.MINOR.PATCH).
61
+ # b. Update CHANGELOG.rst and commit on main.
62
+ # c. On GitHub, draft a Release with tag `vX.Y.Z` (matching the constant)
63
+ # and publish it. This workflow runs automatically: it verifies the tag
64
+ # matches the gemspec version, builds the gem, and pushes it.
65
+ #
66
+ # ----------------------------------------------------------------------------
67
+ # Fallback: API key (only if Trusted Publishing isn't an option)
68
+ # ----------------------------------------------------------------------------
69
+ #
70
+ # 1. Create an API key at https://rubygems.org/profile/api_keys with scope
71
+ # "Push rubygem" restricted to the `familia` gem.
72
+ # 2. Store it as a repo secret named `RUBYGEMS_API_KEY`.
73
+ # 3. Drop the `id-token: write` permission and `environment:` block, and
74
+ # replace the `rubygems/release-gem` step with:
75
+ #
76
+ # - name: Publish to RubyGems
77
+ # env:
78
+ # GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
79
+ # run: gem push familia-*.gem
80
+ #
81
+ # ----------------------------------------------------------------------------
82
+ # Action provenance (SHA pins)
83
+ # ----------------------------------------------------------------------------
84
+ #
85
+ # All third-party actions below are pinned to a full commit SHA, per GitHub's
86
+ # secure-use guidance for release-critical workflows. The accompanying
87
+ # comment records the tag the SHA was resolved from so renovate/dependabot
88
+ # can keep them current.
89
+ #
90
+ # actions/checkout - official GitHub action
91
+ # ruby/setup-ruby - official Ruby org action (GitHub-verified creator)
92
+ # rubygems/release-gem - official RubyGems action for trusted publishing
93
+ #
94
+ # ============================================================================
95
+
96
+ name: Release Gem
97
+
98
+ on:
99
+ release:
100
+ types: [published]
101
+
102
+ # Coarse default. Each job re-declares the narrowest permissions it needs.
103
+ permissions:
104
+ contents: read
105
+
106
+ # Don't allow two release runs to race - one published tag, one publish.
107
+ concurrency:
108
+ group: release-gem-${{ github.event.release.tag_name }}
109
+ cancel-in-progress: false
110
+
111
+ jobs:
112
+ release:
113
+ name: Build and push gem
114
+ runs-on: ubuntu-latest
115
+ timeout-minutes: 10
116
+
117
+ environment:
118
+ name: rubygems.org
119
+ url: https://rubygems.org/gems/familia
120
+
121
+ permissions:
122
+ contents: write # rubygems/release-gem attaches built .gem to the GitHub release
123
+ id-token: write # OIDC token for RubyGems Trusted Publishing
124
+
125
+ steps:
126
+ - name: Checkout
127
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
128
+ with:
129
+ persist-credentials: false
130
+
131
+ - name: Set up Ruby
132
+ uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
133
+ with:
134
+ bundler-cache: true
135
+ # Pinned to 3.2, the oldest non-experimental Ruby in ci.yml's matrix
136
+ # and the gemspec's required_ruby_version floor, so the release build
137
+ # matches the minimum supported configuration that CI exercises.
138
+ ruby-version: "3.2"
139
+
140
+ - name: Verify release tag matches Familia::VERSION
141
+ env:
142
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
143
+ run: |
144
+ set -euo pipefail
145
+ case "${RELEASE_TAG}" in
146
+ v[0-9]*.[0-9]*.[0-9]*) ;;
147
+ *)
148
+ echo "Release tag '${RELEASE_TAG}' is not a vMAJOR.MINOR.PATCH semver tag." >&2
149
+ exit 1
150
+ ;;
151
+ esac
152
+ tag_version="${RELEASE_TAG#v}"
153
+ gem_version="$(ruby -r ./lib/familia/version.rb -e 'print Familia::VERSION')"
154
+ if [ "${tag_version}" != "${gem_version}" ]; then
155
+ echo "Release tag ${RELEASE_TAG} (${tag_version}) != Familia::VERSION (${gem_version})." >&2
156
+ exit 1
157
+ fi
158
+ echo "Releasing familia ${gem_version} from tag ${RELEASE_TAG}"
159
+
160
+ - name: Build and push gem to RubyGems
161
+ uses: rubygems/release-gem@6317d8d1f7e28c24d28f6eff169ea854948bd9f7 # v1.2.0
data/.rubocop.yml CHANGED
@@ -135,9 +135,11 @@ Style/StringLiterals:
135
135
  Enabled: true
136
136
  EnforcedStyle: single_quotes
137
137
 
138
+ # Every lib/ file carries `# frozen_string_literal: true`; require it rather
139
+ # than flag it (the previous `never` contradicted the entire codebase).
138
140
  Style/FrozenStringLiteralComment:
139
141
  Enabled: true
140
- EnforcedStyle: never
142
+ EnforcedStyle: always_true
141
143
 
142
144
  Naming/MemoizedInstanceVariableName:
143
145
  Enabled: false
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,97 @@ 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.11.0:
11
+
12
+ 2.11.0 — 2026-06-22
13
+ ===================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Project-wide relationship introspection: ``Familia.index_descriptors``,
19
+ ``Familia.unique_indexes``, ``Familia.multi_indexes``, and
20
+ ``Familia.participation_descriptors`` aggregate index/participation metadata
21
+ across all loaded ``Horreum`` subclasses, returning ``Familia::IndexDescriptor``
22
+ objects (``coordinate``, ``each_record``, ``rebuild!``, ``stale_format?``).
23
+
24
+ - ``Familia.stale_indexes`` and ``Familia.assert_indexes_current!`` detect
25
+ class-level unique indexes still holding pre-2.10.0 JSON-encoded identifiers and
26
+ fail fast (or warn) at boot. Rebuild with ``Familia.stale_indexes.each(&:rebuild!)``.
27
+
28
+ - ``Familia.legacy_json_encoded?`` predicate for detecting legacy-format
29
+ identifiers.
30
+
31
+ Changed
32
+ -------
33
+
34
+ - New ``encryption_hkdf_salt`` and ``encryption_hkdf_salt_history`` settings
35
+ configure the AES-GCM HKDF salt, decoupled from ``encryption_personalization``
36
+ (now used only by the XChaCha20/BLAKE2b providers, still capped at 16 bytes). The
37
+ salt has no length limit and supports rotation; no data migration is required
38
+ (issue #311).
39
+
40
+ - ``feature :external_identifier`` accepts a callable ``secret:``, resolved lazily
41
+ at first use (issue #311).
42
+
43
+ - The opt-in request-scoped key cache keys on the resolved salt, so an encrypt and a
44
+ later decrypt of the same value within one request share a single derived key
45
+ (issue #311).
46
+
47
+ Fixed
48
+ -----
49
+
50
+ - ``SortedSet#members(n)`` / ``#revmembers(n)`` returned one fewer element than
51
+ requested.
52
+
53
+ - Participation permission queries (``<collection>_with_permission``) now return
54
+ matching members instead of raising on a non-existent ``zrangebyscore`` call.
55
+
56
+ - Class-level ``Model.destroy!(id)`` now removes the record's unique-index entries
57
+ instead of leaving them orphaned, so a stale ``find_by_<field>`` can no longer
58
+ resolve a deleted record.
59
+
60
+ - Changing a ``unique_index`` field value and saving now removes the old value's
61
+ index entry in the same transaction (``multi_index`` keeps add-only semantics).
62
+
63
+ Security
64
+ --------
65
+
66
+ - ``Familia::VerifiableIdentifier`` now requires ``VERIFIABLE_ID_HMAC_SECRET``
67
+ (issue #310, S1). The committed fallback secret, which allowed identifier
68
+ forgery, is removed; the secret is read lazily, so requiring the file without it
69
+ set does not raise.
70
+
71
+ - AES-GCM keys derive from a per-deployment ``encryption_hkdf_salt`` instead of a
72
+ static library salt (issue #310, S2). A blank salt or personalization now raises
73
+ rather than silently using a weak/global value; existing ciphertext still
74
+ decrypts (issue #311).
75
+
76
+ - External identifiers derive via SHA-256, or keyed HMAC-SHA256 with a ``secret:``,
77
+ instead of a Mersenne-Twister PRNG seeded from a truncated digest (issue #310, S3).
78
+
79
+ - ``ParticipationMembership#target_instance`` resolves class names through the
80
+ ``Familia.resolve_class`` allowlist instead of ``Object.const_get`` (issue #310, S4).
81
+
82
+ - The request-scoped key cache is wiped on entry to ``with_request_cache`` as well
83
+ as on exit, so a reused fiber cannot inherit another request's keys (issue #310, S6).
84
+
85
+ Documentation
86
+ -------------
87
+
88
+ - Documented the relationship introspection API and stale-index boot guard across
89
+ the relationships guide and methods reference. Renamed
90
+ ``docs/migrating/v2.10.0.md`` to ``docs/migrating/v2.10.md``.
91
+
92
+ - Clarified that the migration ``Script`` SHA-1 is the Redis ``EVALSHA`` identity,
93
+ not a security checksum (issue #310, S5).
94
+
95
+ AI Assistance
96
+ -------------
97
+
98
+ - The 2.11.0 changes, their tryouts, and this changelog were drafted with AI
99
+ assistance.
100
+
10
101
  .. _changelog-2.10.1:
11
102
 
12
103
  2.10.1 — 2026-06-06
data/Gemfile CHANGED
@@ -15,6 +15,12 @@ end
15
15
  group :development, :test do
16
16
  gem 'benchmark', '~> 0.4', require: false
17
17
  gem 'debug', require: false
18
+ # reek pulls in dry-configurable transitively (via dry-schema). Its 1.4.0
19
+ # release bumped the minimum Ruby to 3.3, which breaks `bundle install` on
20
+ # Ruby 3.2 (our gemspec's required_ruby_version floor). 1.4.0 only adds
21
+ # Config#to_data, which we don't use (we don't use Dry::Configurable at all),
22
+ # so cap below 1.4 to keep the dev bundle installable on Ruby 3.2.
23
+ gem 'dry-configurable', '>= 1.3', '< 1.5', require: false
18
24
  gem 'irb', '~> 1.15.2', require: false
19
25
  gem 'json_schemer', '~> 2.0', require: false
20
26
  gem 'rake', '~> 13.0', require: false
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (2.10.1)
4
+ familia (2.11.0)
5
5
  concurrent-ruby (~> 1.3)
6
6
  connection_pool (>= 2.4, < 4.0)
7
7
  csv (~> 3.3)
@@ -29,8 +29,8 @@ GEM
29
29
  irb (~> 1.10)
30
30
  reline (>= 0.3.8)
31
31
  diff-lcs (1.6.2)
32
- dry-configurable (1.4.0)
33
- dry-core (~> 1.0)
32
+ dry-configurable (1.3.0)
33
+ dry-core (~> 1.1)
34
34
  zeitwerk (~> 2.6)
35
35
  dry-core (1.2.0)
36
36
  concurrent-ruby (~> 1.0)
@@ -204,6 +204,7 @@ DEPENDENCIES
204
204
  benchmark (~> 0.4)
205
205
  concurrent-ruby (~> 1.3.6)
206
206
  debug
207
+ dry-configurable (>= 1.3, < 1.5)
207
208
  familia!
208
209
  irb (~> 1.15.2)
209
210
  json_schemer (~> 2.0)
data/README.md CHANGED
@@ -475,6 +475,32 @@ Contributions are welcome! Please feel free to submit a Pull Request.
475
475
  4. Push to the branch (`git push origin feature/amazing-feature`)
476
476
  5. Open a Pull Request
477
477
 
478
+ ### Running the Tests
479
+
480
+ Familia's test suite uses the [Tryouts](https://github.com/delano/tryouts)
481
+ framework. The simplest way to run it:
482
+
483
+ ```bash
484
+ bundle install
485
+ bundle exec rake test # full suite, with a guaranteed UTF-8 locale
486
+ ```
487
+
488
+ `rake test` runs the suite in a child process with a UTF-8 locale, which the
489
+ suite needs: some tryouts contain UTF-8 source (e.g. Unicode cases) and assert on
490
+ string encodings, so when no locale is set Ruby falls back to
491
+ `Encoding.default_external = US-ASCII`, the runner aborts with `invalid byte
492
+ sequence in US-ASCII`, and encoding specs fail spuriously. (A locale you already
493
+ have is kept; most shells and CI provide a UTF-8 one.)
494
+
495
+ To invoke the runner directly — e.g. for a single file — ensure your shell has a
496
+ UTF-8 locale first:
497
+
498
+ ```bash
499
+ export LANG=C.UTF-8 # or en_US.UTF-8; only needed if unset
500
+ bundle exec try -vf # full suite
501
+ bundle exec try try/path/to/foo_try.rb # a single file
502
+ ```
503
+
478
504
  ### PR Compliance Checks
479
505
 
480
506
  Pull requests are automatically reviewed by [Qodo Merge](https://qodo.ai) with compliance checks for:
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Run the Tryouts test suite with a guaranteed UTF-8 locale.
4
+ #
5
+ # The suite's source and specs are UTF-8. The tryouts runner reads each test file
6
+ # with File.read, which honours Encoding.default_external; when the caller's
7
+ # locale is unset that defaults to US-ASCII and the run aborts on non-ASCII source
8
+ # ("invalid byte sequence in US-ASCII"), with encoding-sensitive specs failing
9
+ # spuriously. Running the suite in a child process with a UTF-8 locale makes it
10
+ # work regardless of the caller's environment. A caller that already has a UTF-8
11
+ # locale (CI, most shells) keeps it -- the defaults below only fill in when unset.
12
+ desc 'Run the Tryouts test suite (ensures a UTF-8 locale)'
13
+ task :test do
14
+ ENV['LANG'] ||= 'C.UTF-8'
15
+ ENV['LC_ALL'] ||= 'C.UTF-8'
16
+ sh 'bundle exec try -vf'
17
+ end
18
+
19
+ task default: :test
@@ -892,7 +892,10 @@ end
892
892
  Familia.configure do |config|
893
893
  config.encryption_keys = { v1: key, v2: new_key }
894
894
  config.current_key_version = :v2
895
- config.encryption_personalization = 'MyApp-2024' # XChaCha20 only
895
+ config.encryption_personalization = 'MyApp-2024' # XChaCha20 BLAKE2b domain separation (<= 16 bytes)
896
+ config.encryption_hkdf_salt = 'MyApp-2024' # AES-GCM HKDF salt domain separation (any length)
897
+ # When rotating the AES-GCM salt, keep prior values for backward-compatible decryption:
898
+ # config.encryption_hkdf_salt_history = ['MyApp-2023']
896
899
  end
897
900
 
898
901
  # Validate configuration
@@ -160,7 +160,8 @@ Familia.configure do |config|
160
160
  v3: ENV['NEW_KEY'] # New key for rotation
161
161
  }
162
162
  config.current_key_version = :v2
163
- config.encryption_personalization = 'MyApp-2024' # Optional (XChaCha20 only)
163
+ config.encryption_personalization = 'MyApp-2024' # XChaCha20 BLAKE2b domain separation (<= 16 bytes)
164
+ config.encryption_hkdf_salt = 'MyApp-2024' # AES-GCM HKDF salt domain separation (any length)
164
165
  end
165
166
 
166
167
  # Operations with encrypted fields
@@ -83,7 +83,7 @@ module Familia
83
83
  # Register middleware only once, globally
84
84
  register_middleware_once
85
85
 
86
- Redis.new(parsed_uri.conf)
86
+ Redis.new(parsed_uri.conf.merge(timeout: 5))
87
87
  end
88
88
  alias connect create_dbclient # backwards compatibility
89
89
  alias isolated_dbclient create_dbclient # matches with_isolated_dbclient api
@@ -0,0 +1,57 @@
1
+ # lib/familia/core_ext/securerandom.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'securerandom'
6
+
7
+ # Polyfills for SecureRandom.uuid_v7 and SecureRandom.uuid_v4 -- Ruby 3.2 only.
8
+ #
9
+ # Both named methods entered Ruby's standard library in Ruby 3.3. Ruby 3.2 is
10
+ # the oldest version familia supports (the gemspec's required_ruby_version
11
+ # floor), and there only SecureRandom.uuid exists (it produces a v4 UUID).
12
+ # Familia's :object_identifier feature offers both :uuid_v7 (the default, for
13
+ # its embedded sortable millisecond timestamp) and :uuid_v4 generators, so on
14
+ # Ruby 3.2 we supply faithful fallbacks.
15
+ #
16
+ # The RUBY_VERSION guard below ensures these definitions exist ONLY on Ruby
17
+ # 3.2: on Ruby 3.3+ the block is skipped entirely and the native stdlib
18
+ # implementations are left untouched. The caller (lib/familia/secure_identifier.rb)
19
+ # also gates the require on RUBY_VERSION, so on 3.3+ this file is normally never
20
+ # loaded -- the guard here is a second line of defence in case it is required
21
+ # directly.
22
+ if RUBY_VERSION < '3.3'
23
+ # UUIDv4 is exactly what the long-standing SecureRandom.uuid returns, so the
24
+ # fallback simply delegates to it.
25
+ def SecureRandom.uuid_v4
26
+ uuid
27
+ end
28
+
29
+ # UUIDv7 layout (RFC 9562, millisecond precision):
30
+ #
31
+ # field bits description
32
+ # unix_ts_ms 48 Unix timestamp in milliseconds, big-endian
33
+ # ver 4 version, always 0b0111 (7)
34
+ # rand_a 12 random
35
+ # var 2 variant, always 0b10
36
+ # rand_b 62 random
37
+ def SecureRandom.uuid_v7
38
+ unix_ts_ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
39
+
40
+ # 48-bit timestamp split across the first two groups (32 + 16 bits).
41
+ time_hi = (unix_ts_ms >> 16) & 0xffff_ffff
42
+ time_lo = unix_ts_ms & 0xffff
43
+
44
+ # Third group: version 7 in the top nibble, then 12 random bits.
45
+ ver_rand_a = 0x7000 | random_number(0x1000)
46
+
47
+ # Fourth group: variant 0b10 in the top two bits, then 14 random bits.
48
+ var_rand_b_hi = 0x8000 | random_number(0x4000)
49
+
50
+ # Fifth group: the remaining 48 random bits.
51
+ rand_b_lo = random_number(0x1_0000_0000_0000)
52
+
53
+ format('%<time_hi>08x-%<time_lo>04x-%<ver_rand_a>04x-%<var_rand_b_hi>04x-%<rand_b_lo>012x',
54
+ time_hi: time_hi, time_lo: time_lo, ver_rand_a: ver_rand_a,
55
+ var_rand_b_hi: var_rand_b_hi, rand_b_lo: rand_b_lo)
56
+ end
57
+ end
@@ -205,7 +205,8 @@ module Familia
205
205
  end
206
206
 
207
207
  def members(count = -1, opts = {})
208
- count -= 1 if count.positive?
208
+ # NOTE: count math (positive count -> end index) is handled once by
209
+ # membersraw. Do not decrement here too, or members(n) returns n-1.
209
210
  elements = membersraw count, opts
210
211
  deserialize_values(*elements)
211
212
  end
@@ -218,7 +219,8 @@ module Familia
218
219
  end
219
220
 
220
221
  def revmembers(count = -1, opts = {})
221
- count -= 1 if count.positive?
222
+ # See #members: revmembersraw already converts a positive count to the
223
+ # correct end index; decrementing here as well would drop one element.
222
224
  elements = revmembersraw count, opts
223
225
  deserialize_values(*elements)
224
226
  end
@@ -57,14 +57,35 @@ module Familia
57
57
 
58
58
  # Validate algorithm support
59
59
  provider = Registry.get(data.algorithm)
60
- key = derive_key_without_increment(context, version: data.key_version, provider: provider)
61
60
 
62
61
  # Safely decode and validate sizes
63
62
  nonce = decode_and_validate(data.nonce, provider.nonce_size, 'nonce')
64
63
  ciphertext = decode_and_validate_ciphertext(data.ciphertext)
65
64
  auth_tag = decode_and_validate(data.auth_tag, provider.auth_tag_size, 'auth_tag')
66
65
 
67
- plaintext = provider.decrypt(ciphertext, key, nonce, auth_tag, additional_data)
66
+ # Try each candidate HKDF salt, current first, so ciphertext written
67
+ # before a salt change still decrypts. Providers without salt rotation
68
+ # expose a single nil "salt" and are attempted exactly once. A wrong
69
+ # salt derives a different key and fails the authenticated decrypt
70
+ # cleanly, so iterating never yields a false positive. See #310 (S2).
71
+ salts = provider.respond_to?(:hkdf_salts) ? provider.hkdf_salts : [nil]
72
+ key = nil
73
+ plaintext = nil
74
+ last_error = nil
75
+ salts.each do |salt|
76
+ key = derive_key_without_increment(context, version: data.key_version, provider: provider, salt: salt)
77
+ begin
78
+ plaintext = provider.decrypt(ciphertext, key, nonce, auth_tag, additional_data)
79
+ break
80
+ rescue EncryptionError => e
81
+ last_error = e
82
+ plaintext = nil
83
+ ensure
84
+ Familia::Encryption.secure_wipe(key)
85
+ end
86
+ end
87
+ raise(last_error || EncryptionError.new('Decryption failed - invalid key or corrupted data')) if plaintext.nil?
88
+
68
89
  plaintext.force_encoding(data.encoding || 'UTF-8')
69
90
  rescue EncryptionError
70
91
  raise
@@ -74,6 +95,11 @@ module Familia
74
95
  raise EncryptionError, "Decryption failed: #{e.message}"
75
96
  end
76
97
  ensure
98
+ # Defensive backstop only. The salt-rotation loop's per-iteration `ensure`
99
+ # (around provider.decrypt) is the primary wipe and clears `key` on every
100
+ # path -- success, failure, and break -- so by here `key` is already wiped
101
+ # and this re-clear is a harmless no-op. It is kept so any future change to
102
+ # the loop structure still cannot leave a derived key unwiped (#311).
77
103
  Familia::Encryption.secure_wipe(key) if key
78
104
  end
79
105
 
@@ -101,7 +127,7 @@ module Familia
101
127
  derive_key_without_increment(context, version: version, provider: provider)
102
128
  end
103
129
 
104
- def derive_key_without_increment(context, version: nil, provider: nil)
130
+ def derive_key_without_increment(context, version: nil, provider: nil, salt: nil)
105
131
  # Use provided provider or fall back to instance provider
106
132
  provider ||= @provider
107
133
 
@@ -113,18 +139,31 @@ module Familia
113
139
  # Request-scoped key cache (opt-in via Familia::Encryption.with_request_cache).
114
140
  # Disabled by default for maximum security (keys are not held in memory
115
141
  # longer than a single derivation). The cache key includes the algorithm
116
- # so different providers never share a derived key, and the version so
117
- # key rotation stays correct. On a hit we return a copy and never fetch
118
- # the master key, minimising master-key exposure.
142
+ # so different providers never share a derived key, the version so key
143
+ # rotation stays correct, and the resolved salt so rotated-salt derivations
144
+ # never collide. On a hit we return a copy and never fetch the master key,
145
+ # minimising master-key exposure.
119
146
  cache = Fiber[:familia_request_cache] if Fiber[:familia_request_cache_enabled]
120
147
  if cache
121
- cache_key = "#{provider.algorithm}:#{version}:#{context}"
148
+ # Key on the *resolved* salt, not the raw argument. When salt is nil
149
+ # (the encrypt path) the provider derives with hkdf_salts.first; the
150
+ # decrypt loop later passes that same value explicitly. Keying on the
151
+ # raw argument would file those two identical derivations under
152
+ # different keys (nil vs the resolved salt), so an encrypt followed by a
153
+ # decrypt of the same value in one request would derive twice instead of
154
+ # hitting the cache. Providers without salt rotation have no effective
155
+ # salt (nil), so their cache key is unchanged.
156
+ effective_salt = salt || (provider.respond_to?(:hkdf_salts) ? provider.hkdf_salts.first : nil)
157
+ cache_key = "#{provider.algorithm}:#{version}:#{effective_salt}:#{context}"
122
158
  cached = cache[cache_key]
123
159
  return cached.dup if cached
124
160
  end
125
161
 
126
162
  master_key = get_master_key(version)
127
- derived = provider.derive_key(master_key, context)
163
+ # Only forward an explicit salt to providers that accept one (the AES-GCM
164
+ # salt-rotation path). The default derivation keeps the original arity so
165
+ # providers without a salt parameter are unaffected.
166
+ derived = salt.nil? ? provider.derive_key(master_key, context) : provider.derive_key(master_key, context, salt: salt)
128
167
  cache[cache_key] = derived.dup if cache
129
168
  derived
130
169
  ensure