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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -4
- data/.github/workflows/release-gem.yml +161 -0
- data/.rubocop.yml +3 -1
- data/CHANGELOG.rst +91 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +4 -3
- data/README.md +26 -0
- data/Rakefile +19 -0
- data/docs/guides/feature-encrypted-fields.md +4 -1
- data/docs/reference/api-technical.md +2 -1
- data/lib/familia/connection.rb +1 -1
- data/lib/familia/core_ext/securerandom.rb +57 -0
- data/lib/familia/data_type/types/sorted_set.rb +4 -2
- data/lib/familia/encryption/manager.rb +47 -8
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +62 -2
- data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +5 -0
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +6 -0
- data/lib/familia/encryption/request_cache.rb +18 -1
- data/lib/familia/features/encrypted_fields.rb +4 -1
- data/lib/familia/features/external_identifier.rb +50 -19
- data/lib/familia/features/relationships/participation/target_methods.rb +10 -7
- data/lib/familia/features/relationships/participation_membership.rb +12 -5
- data/lib/familia/horreum/management.rb +13 -0
- data/lib/familia/horreum/persistence.rb +58 -16
- data/lib/familia/migration/script.rb +12 -1
- data/lib/familia/secure_identifier.rb +32 -21
- data/lib/familia/settings.rb +66 -8
- data/lib/familia/verifiable_identifier.rb +31 -7
- data/lib/familia/version.rb +1 -1
- data/try/bug_fixes/class_destroy_index_cleanup_try.rb +50 -0
- data/try/bug_fixes/permission_query_try.rb +64 -0
- data/try/bug_fixes/sorted_set_members_count_try.rb +40 -0
- data/try/bug_fixes/stale_unique_index_try.rb +54 -0
- data/try/features/encryption/aes_gcm_salt_rotation_try.rb +219 -0
- data/try/features/encryption/config_persistence_try.rb +35 -0
- data/try/features/encryption/request_cache_try.rb +15 -0
- data/try/features/external_identifier/external_identifier_try.rb +73 -0
- data/try/features/relationships/participation_membership_security_try.rb +64 -0
- data/try/integration/verifiable_identifier_try.rb +24 -3
- data/try/support/stress/atomic_write_ownership_stress.rb +152 -0
- data/try/thread_safety/atomic_write_ownership_race_try.rb +107 -104
- data/try/unit/core/securerandom_polyfill_try.rb +61 -0
- data/try/unit/horreum/auto_indexing_on_save_try.rb +9 -8
- metadata +12 -2
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c322fb98599ca1596829b047f78d414a3216a4e94a61ded4c6d3a4355dd7780a
|
|
4
|
+
data.tar.gz: a0af405d479ba7547b2fe65fe17e7a9b0fc5fb04cfe8b5563ce10726187d0396
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac3aac1967a9730670b6f43f464c001d6c0acad7e599e528d94edb5b60b7939fb59dc5ef320962bfaacd1c204603e36d09647670f2511320a98c9b4fc9f0d715
|
|
7
|
+
data.tar.gz: f5f7e14e48be039962af4bcda0ac6c61248143353f773239c5b4ac0a9e9af498f64618d2f18dc48f903c7349d47e375f6e87c6ed686d7e0bbc6ebb444a4456b7
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -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:
|
|
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.
|
|
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.
|
|
33
|
-
dry-core (~> 1.
|
|
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
|
|
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' #
|
|
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
|
data/lib/familia/connection.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
117
|
-
#
|
|
118
|
-
# the master key,
|
|
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
|
-
|
|
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
|
-
|
|
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
|