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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f929d1f67a0302d3b2f93043c5ac009d8d98195a8c1f3f5c7e79ca45210873dc
4
- data.tar.gz: 1b58091aeb06c9d3a13a3fc57dd710688dd75c8be48d2d950b19bfc400cb00d0
3
+ metadata.gz: 9186b11b608c1e58f0ea0a2c957eac6e88aaeb7ea18012fe133b40c5d77c54e9
4
+ data.tar.gz: 888389931fa6d8854c4632f175db13409c32991905870cdcbb2c16bc53c912cb
5
5
  SHA512:
6
- metadata.gz: b4dbbac7f24a1cb89efa9787e2acbed6ab4f8fa531d84c525344f3069c4d6bd0be2d5a17f014074f700c5af88be2b6c071ed6d1ecb94d1b27fec172bf8d3b169
7
- data.tar.gz: 265319cf5f65851df3e4a7f8a24d3aa4765d001a27e4d89797e10cc372f4515d558b785863810cd71e7f8d577e4388c1b8f98dfe273218824e29fa7f8e3f9183
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 the BCrypt `token_digest`, and every
27
- # consumer previously had to remember to verify it by hand (and to rescue
28
- # BCrypt::Errors::InvalidHash). This is that step, done once, here.
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
- # Timing-safe verification of `token` against this session's stored
47
- # BCrypt digest. Re-hashes the presented token with the stored salt and
48
- # compares the two digests with a constant-time compare, so the response
49
- # time carries no information about how much of the digest matched.
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
- stored = BCrypt::Password.new(token_digest)
56
- ActiveSupport::SecurityUtils.secure_compare(
57
- stored.to_s,
58
- BCrypt::Engine.hash_secret(token, stored.salt)
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
- BCrypt::Password.create(token)
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
- # BCrypt cost factor for the session token digest. Default `nil` means
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
- # When set, value is clamped to BCrypt::Engine::MIN_COST..MAX_COST.
186
- # Applies only to newly-created sessions; existing token_digests keep
187
- # their original cost.
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.
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.31.0"
2
+ VERSION = "0.32.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.31.0
4
+ version: 0.32.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim