standard_id 0.31.0 → 0.32.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/CHANGELOG.md +63 -0
- data/app/models/standard_id/session.rb +55 -13
- data/lib/standard_id/config/schema.rb +17 -11
- data/lib/standard_id/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9186b11b608c1e58f0ea0a2c957eac6e88aaeb7ea18012fe133b40c5d77c54e9
|
|
4
|
+
data.tar.gz: 888389931fa6d8854c4632f175db13409c32991905870cdcbb2c16bc53c912cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 423dc3b53caf3fad0e0ba2ea41230627a07328e27fdb29439bfaaafb0701b1887f78744321c2579ac8018741a96fda684230d488604702b865e5b8193ebecce4
|
|
7
|
+
data.tar.gz: c16d76284752125e7191ca9ae7770a9fed1c3f0af6078f0221bfb294eaddf78f16701c1954147467f9118916d9d662d50dae5b6844589ffc76d65b14af406ab2
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.32.0] - 2026-07-28
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Session token digests now default to HMAC-SHA256 instead of BCrypt.**
|
|
15
|
+
`Session#token_digest` was a BCrypt hash at bcrypt-ruby's default cost 12.
|
|
16
|
+
BCrypt's cost factor exists to make brute-force of a *low-entropy* secret
|
|
17
|
+
expensive; session tokens are `SecureRandom.urlsafe_base64(32)` — 256 bits —
|
|
18
|
+
so guessing one is infeasible at any hash speed, and the stretching bought
|
|
19
|
+
nothing while costing real CPU on every authenticated request.
|
|
20
|
+
|
|
21
|
+
The cost was measured in a consuming app, and it is not small: **~181 ms per
|
|
22
|
+
verify**, paid per *request* rather than per session creation, which collapsed
|
|
23
|
+
API throughput **19.7×** versus the same requests authenticated by JWT (5.07
|
|
24
|
+
vs 99.95 rps at concurrency 1). Every endpoint in that mix shifted by the same
|
|
25
|
+
~185 ms. Note this is CPU, not lock contention — bcrypt releases the GVL, so
|
|
26
|
+
four concurrent verifies complete in 3.87× less wall time than four serial
|
|
27
|
+
ones; it saturates cores rather than serializing requests.
|
|
28
|
+
|
|
29
|
+
The new digest is HMAC-SHA256 under `secret_key_base`, domain-separated from
|
|
30
|
+
`lookup_hash` by both construction and a versioned prefix so the two values
|
|
31
|
+
can never coincide. This aligns session tokens with how the gem already
|
|
32
|
+
handles comparable credentials: `RefreshToken` digests with SHA256, and
|
|
33
|
+
`Session#lookup_hash` is SHA256.
|
|
34
|
+
|
|
35
|
+
**Nothing is stranded.** Existing rows are never rewritten and no token
|
|
36
|
+
rotation is needed. `Session#authenticate_token` reads the scheme off the
|
|
37
|
+
*stored* digest — BCrypt digests are self-identifying by their `$2<x>$`
|
|
38
|
+
prefix — so old and new digests verify side by side indefinitely. Sessions
|
|
39
|
+
issued before upgrading keep working at their old cost until they expire or
|
|
40
|
+
are re-issued; sessions issued after are fast.
|
|
41
|
+
|
|
42
|
+
**Consumer impact — read this if you verify token digests yourself.** If you
|
|
43
|
+
authenticate opaque session tokens through `Session.authenticate_by_token` or
|
|
44
|
+
`Session#authenticate_token` (the API added in 0.30), there is nothing to do.
|
|
45
|
+
|
|
46
|
+
If you still hand-roll the pre-0.30 pattern —
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
BCrypt::Password.new(session.token_digest) == token
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
— **this release breaks you**, and loudly: a newly-created session's digest is
|
|
53
|
+
a 64-character HMAC, so `BCrypt::Password.new` raises
|
|
54
|
+
`BCrypt::Errors::InvalidHash` (or, if you rescue that, rejects every new
|
|
55
|
+
session). That pattern was the only option before 0.30, so it is worth
|
|
56
|
+
grepping for. Two ways out, either fine:
|
|
57
|
+
|
|
58
|
+
1. **Switch to the gem's verifier** (recommended, and correct on both
|
|
59
|
+
schemes): `StandardId::Session.api_compatible.active.authenticate_by_token(token)`,
|
|
60
|
+
or `session.authenticate_token(token)` if you already hold the row.
|
|
61
|
+
2. **Stay on BCrypt for now** by setting `config.session.token_digest_cost`
|
|
62
|
+
to your preferred cost. New sessions keep being BCrypt-digested and your
|
|
63
|
+
existing code keeps working, so you can migrate callers on your own
|
|
64
|
+
schedule. You can flip it back at any time — the scheme is read off each
|
|
65
|
+
stored digest, so mixing the two is always safe.
|
|
66
|
+
|
|
67
|
+
**If you want BCrypt anyway**, set `config.session.token_digest_cost` to the
|
|
68
|
+
cost you want; that setting now opts *in* to BCrypt rather than merely tuning
|
|
69
|
+
it. Its previous meaning is preserved for anyone who set it deliberately, and
|
|
70
|
+
changing it remains safe in both directions because the scheme is read off the
|
|
71
|
+
stored digest, not off configuration.
|
|
72
|
+
|
|
10
73
|
## [0.31.0] - 2026-07-27
|
|
11
74
|
|
|
12
75
|
### Fixed
|
|
@@ -23,9 +23,10 @@ module StandardId
|
|
|
23
23
|
#
|
|
24
24
|
# `by_token` is NOT authentication on its own: it matches the SHA256
|
|
25
25
|
# `lookup_hash`, which exists only to find the candidate row from an
|
|
26
|
-
# indexed column. The credential is
|
|
27
|
-
#
|
|
28
|
-
#
|
|
26
|
+
# indexed column. The credential is `token_digest` (see #authenticate_token
|
|
27
|
+
# for the two schemes it may hold), and every consumer previously had to
|
|
28
|
+
# remember to verify it by hand — and to rescue BCrypt::Errors::InvalidHash.
|
|
29
|
+
# This is that step, done once, here.
|
|
29
30
|
#
|
|
30
31
|
# Honours the current scope, so callers keep their own filters:
|
|
31
32
|
#
|
|
@@ -43,20 +44,55 @@ module StandardId
|
|
|
43
44
|
session.authenticate_token(token) ? session : nil
|
|
44
45
|
end
|
|
45
46
|
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
47
|
+
# Keyed digest of an opaque session token — the default scheme.
|
|
48
|
+
#
|
|
49
|
+
# HMAC-SHA256 under `secret_key_base`, domain-separated from `lookup_hash`
|
|
50
|
+
# by both construction (HMAC vs plain SHA256) and an explicit versioned
|
|
51
|
+
# prefix, so the two stored values can never coincide even though both
|
|
52
|
+
# derive from the same token and secret.
|
|
53
|
+
#
|
|
54
|
+
# Why not BCrypt (which this was, and which `token_digest_cost` still
|
|
55
|
+
# selects): BCrypt's cost factor exists to make brute-force of a LOW-entropy
|
|
56
|
+
# secret expensive. Session tokens are `SecureRandom.urlsafe_base64(32)` —
|
|
57
|
+
# 256 bits — so guessing one is infeasible regardless of how fast the hash
|
|
58
|
+
# is, and the stretching bought nothing while costing a measured ~181 ms of
|
|
59
|
+
# CPU on EVERY authenticated request (cost 12). That is the same reasoning
|
|
60
|
+
# under which `RefreshToken` digests with plain SHA256 and `lookup_hash`
|
|
61
|
+
# with SHA256; this brings session tokens in line with both.
|
|
62
|
+
DIGEST_PREFIX = "standard_id.session.token_digest.v1:".freeze
|
|
63
|
+
|
|
64
|
+
def self.hmac_token_digest(token)
|
|
65
|
+
OpenSSL::HMAC.hexdigest(
|
|
66
|
+
"SHA256", Rails.configuration.secret_key_base, "#{DIGEST_PREFIX}#{token}"
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Timing-safe verification of `token` against this session's stored digest.
|
|
71
|
+
#
|
|
72
|
+
# Handles BOTH schemes, because digests written before the HMAC default —
|
|
73
|
+
# and any written since by an app that sets `token_digest_cost` — are
|
|
74
|
+
# BCrypt. A BCrypt digest is self-identifying by its `$2<x>$` prefix, so the
|
|
75
|
+
# scheme is read off the stored value rather than off configuration; that
|
|
76
|
+
# way a config change never strands existing sessions, and no rewrite of
|
|
77
|
+
# stored digests is needed. Both branches end in a constant-time compare, so
|
|
78
|
+
# the response time carries no information about how much of the digest
|
|
79
|
+
# matched.
|
|
50
80
|
#
|
|
51
81
|
# @return [Boolean] false for a blank or malformed digest — never raises.
|
|
52
82
|
def authenticate_token(token)
|
|
53
83
|
return false if token.blank? || token_digest.blank?
|
|
54
84
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
85
|
+
if token_digest.start_with?("$2")
|
|
86
|
+
stored = BCrypt::Password.new(token_digest)
|
|
87
|
+
ActiveSupport::SecurityUtils.secure_compare(
|
|
88
|
+
stored.to_s,
|
|
89
|
+
BCrypt::Engine.hash_secret(token, stored.salt)
|
|
90
|
+
)
|
|
91
|
+
else
|
|
92
|
+
ActiveSupport::SecurityUtils.secure_compare(
|
|
93
|
+
token_digest, self.class.hmac_token_digest(token)
|
|
94
|
+
)
|
|
95
|
+
end
|
|
60
96
|
rescue BCrypt::Errors::InvalidHash, BCrypt::Errors::InvalidSalt
|
|
61
97
|
false
|
|
62
98
|
end
|
|
@@ -94,11 +130,17 @@ module StandardId
|
|
|
94
130
|
@token ||= SecureRandom.urlsafe_base64(32)
|
|
95
131
|
end
|
|
96
132
|
|
|
133
|
+
# HMAC by default; BCrypt only when an app explicitly asks for it by setting
|
|
134
|
+
# `token_digest_cost`. That inverts the previous default (BCrypt at
|
|
135
|
+
# bcrypt-ruby's cost 12) — see .hmac_token_digest for why — while leaving
|
|
136
|
+
# the escape hatch meaning exactly what it says for anyone who set it
|
|
137
|
+
# deliberately. Existing rows are untouched either way: #authenticate_token
|
|
138
|
+
# reads the scheme off the stored digest, not off this config.
|
|
97
139
|
def generate_token_digest
|
|
98
140
|
configured_cost = StandardId.config.session.token_digest_cost
|
|
99
141
|
self.token_digest =
|
|
100
142
|
if configured_cost.nil?
|
|
101
|
-
|
|
143
|
+
self.class.hmac_token_digest(token)
|
|
102
144
|
else
|
|
103
145
|
cost = configured_cost.clamp(BCrypt::Engine::MIN_COST, BCrypt::Engine::MAX_COST)
|
|
104
146
|
BCrypt::Password.create(token, cost: cost)
|
|
@@ -173,18 +173,24 @@ StandardId::ConfigSchema.define do
|
|
|
173
173
|
field :device_session_lifetime, type: :integer, default: 2592000 # 30 days in seconds
|
|
174
174
|
field :service_session_lifetime, type: :integer, default: 7776000 # 90 days in seconds
|
|
175
175
|
|
|
176
|
-
#
|
|
177
|
-
# use bcrypt-ruby's built-in default (cost 12 in production, MIN_COST
|
|
178
|
-
# in the test env). Since session tokens are 256-bit random
|
|
179
|
-
# (`SecureRandom.urlsafe_base64(32)`), any cost >= 10 is well beyond
|
|
180
|
-
# realistic brute-force, and dropping from 12 to 10 saves ~200ms of
|
|
181
|
-
# CPU per session creation. Host apps with many logins-per-second can
|
|
182
|
-
# set this to `10`; apps that value hash work over login latency can
|
|
183
|
-
# leave it alone or raise it.
|
|
176
|
+
# Opt IN to BCrypt for the session token digest, at this cost factor.
|
|
184
177
|
#
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
178
|
+
# Default `nil` means HMAC-SHA256 under `secret_key_base`, which is the
|
|
179
|
+
# right primitive here: session tokens are 256-bit random
|
|
180
|
+
# (`SecureRandom.urlsafe_base64(32)`), and BCrypt's cost factor exists to
|
|
181
|
+
# slow brute-force of LOW-entropy secrets. Against a token that cannot be
|
|
182
|
+
# guessed at any hash speed the stretching bought nothing, while costing a
|
|
183
|
+
# measured ~181 ms of CPU on every authenticated API request at cost 12 —
|
|
184
|
+
# note this is paid per REQUEST, not merely per session creation.
|
|
185
|
+
#
|
|
186
|
+
# Set it only if you specifically want hash work on this path anyway. When
|
|
187
|
+
# set, the value is clamped to BCrypt::Engine::MIN_COST..MAX_COST.
|
|
188
|
+
#
|
|
189
|
+
# Changing it is always safe and never strands a session:
|
|
190
|
+
# Session#authenticate_token reads the scheme off the stored digest (BCrypt
|
|
191
|
+
# digests are self-identifying by their `$2<x>$` prefix), so both schemes
|
|
192
|
+
# verify side by side and existing rows are never rewritten. As before, a
|
|
193
|
+
# change applies only to newly-created sessions.
|
|
188
194
|
field :token_digest_cost, type: :integer, default: nil
|
|
189
195
|
|
|
190
196
|
# Callable that resolves the session class to create for a given auth flow.
|
data/lib/standard_id/version.rb
CHANGED