feat: keep access tokens valid across a pepper change - #2162
Open
netomi wants to merge 3 commits into
Open
Conversation
Changing ovsx.access-token.token-hash-pepper invalidates every token in the registry, because the raw value is never stored: a row holds only its hash, so nothing can rehash it under a new pepper except the holder presenting the token again. The v0 upgrade is no template - it works only because v0 rows still carried their raw value. So rotation has to happen on the verification side. useAccessToken now retries a token that misses under the current pepper against each pepper the instance used before, and rewrites the row it finds, migrating each token to the current pepper as it gets used. The retry sits before the existing v0 fallback and costs one query per configured pepper, only for a token not used since the change; an instance that is not mid-rotation has an empty keyring and does no extra work. Two properties drive it. token-hash-previous-peppers is the list of retired peppers. token-hash-accept-unpeppered covers the case most instances are actually in: the pepper defaults to the empty string, so adopting one for the first time is itself a rotation, and its previous pepper is the empty string - which a comma separated list cannot express, hence the flag. Both feed one deduplicated keyring that drops the current pepper, since that is always tried first. What this cannot do is finish the migration early. No background job can rehash a token nobody presents, so a retired pepper must stay configured until every row that might use it is gone - bounded by ovsx.access-token.expiration plus a sweep, and unbounded where expiry is disabled, because an unused token then lives forever. Documented on the property rather than left for an operator to discover. Also rejects a current pepper containing a comma, at startup rather than at the rotation where it would split into two wrong peppers and look like data loss. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pepper defaults to the empty string and no shipped configuration sets one, so the common case is a registry storing its access tokens as unkeyed hashes - and the recommendation against that lived only in a javadoc, which nobody deploying the server reads. Not an error, and not startup-refusing: the default has to keep working, and until the keyring in the previous commit there was no way to adopt a pepper without invalidating every existing token. Now that there is, the warning can say what to set and how to set it without logging anyone out, which is what makes it actionable rather than nagging. Checks hasText rather than isEmpty, because a pepper of blanks is no more of a secret than none at all, and whether whitespace survives a property source at all depends on how it was quoted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
netomi
force-pushed
the
feat/token-hash-pepper-keyring
branch
from
September 4, 2026 20:18
cf621f5 to
5cea58b
Compare
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
token-hash-previous-peppers can currently admit blank/empty list entries, which may unintentionally enable unpeppered-token fallback without the explicit token-hash-accept-unpeppered flag.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR adds support for lazy personal access token hash “pepper” rotation by retrying verification against a configured keyring of previous peppers and rehashing the token row on successful match, so existing tokens remain valid across a pepper change without requiring the raw token to be stored.
Changes:
- Add pepper-rotation fallback lookup in
AccessTokenService.useAccessToken()that tries prior peppers and rewrites the stored hash on match. - Introduce new config properties and derived “pepper keyring” in
AccessTokenConfig, plus a startup warning when no pepper is configured. - Add focused unit tests covering keyring derivation/binding and rotation behavior.
File summaries
| File | Description |
|---|---|
| server/src/main/java/org/eclipse/openvsx/accesstoken/AccessTokenService.java | Retries token lookup using previous peppers and rehashes rows to the current pepper on successful match. |
| server/src/main/java/org/eclipse/openvsx/accesstoken/AccessTokenConfig.java | Adds config for previous peppers/unpeppered acceptance, derives an immutable keyring, and logs a startup warning when pepper is blank. |
| server/src/test/java/org/eclipse/openvsx/accesstoken/AccessTokenServiceTest.java | Adds rotation/adoption tests ensuring fallback is ordered, stops on first match, and avoids extra lookups on current-pepper hits. |
| server/src/test/java/org/eclipse/openvsx/accesstoken/AccessTokenConfigTest.java | New tests validating property binding/order/dedup/current-pepper removal/comma rejection and warning behavior. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A trailing or doubled comma leaves a blank element behind - Spring trims each one, so an all-whitespace entry arrives blank too - and a blank pepper is the unpeppered hash. That is precisely what token-hash-accept-unpeppered gates, kept as its own flag because a comma separated list cannot express an empty entry unambiguously. Taking one at face value let a stray comma switch on acceptance of unpeppered tokens with nobody asking for it. Filter them out, and say so where the property is documented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #2159 — based on
refactor/token-hash-pepper, notmain, so review that one first and this diff stays to the keyring itself. Retarget tomainonce #2159 merges.Two commits: the keyring, then the startup warning that the keyring makes actionable.
The problem
Changing
ovsx.access-token.token-hash-pepperinvalidates every token in the registry. The raw value is never stored —createAccessTokenhashes it on the way in (AccessTokenService.java:179) and the row keeps only the digest — so nothing can rehash a row under a new pepper except the holder presenting the token again. The v0→v1 upgrade is no template:upgradeTokenworks only because v0 rows still literally carried their raw value.That matters most for the case nearly every instance is actually in. The pepper defaults to the empty string and no shipped configuration sets one, so adopting a pepper is itself a rotation — and today it logs out every user's tokens at once.
The approach
Rotation can't happen on the stored side, so it happens on the verification side.
useAccessTokenretries a token that misses under the current pepper against each pepper the instance used before, and rewrites the row it finds:Each token migrates to the current pepper the next time it is used. The retry sits ahead of the existing v0 fallback, and the rewrite happens before the active/expired/scope checks — matching what the v0 upgrade already does, since which pepper hashed a row says nothing about whether that token is usable.
Configuration
ovsx.access-token.token-hash-previous-peppersovsx.access-token.token-hash-accept-unpepperedThe flag exists because the unpeppered case is a previous pepper of
"", which a comma separated list cannot express unambiguously. Both feed one deduplicated keyring that drops the current pepper, since that is always tried first.The startup warning
An instance with no pepper now says so once at boot, naming what it costs and what to do:
WARN, not a startup failure: the empty default has to keep working. It belongs in this PR rather than on its own, because before the keyring the only honest advice was "set a pepper and invalidate every token" — the warning is worth making only once acting on it is safe. CheckshasTextrather thanisEmpty, since a pepper of blanks is no more of a secret than none.To be clear about what the empty default does and does not cost: token values are 256 CSPRNG bits, so a leaked hash column still yields no usable tokens either way. What an unkeyed hash gives away is an offline confirmation oracle for a token found elsewhere — no rate limit, no access-log entry, no
accessed_timestampbump — and the ability to join hashes across dumps and instances, since unkeyed SHA-256 is globally deterministic. Worth a warning, not worth a refusal to start.What this deliberately does not do
It cannot finish the migration early. No background job can rehash a token nobody presents, so a retired pepper must stay configured until every row that might still use it is gone — bounded by
ovsx.access-token.expirationplus one expiry sweep, and unbounded where expiry is disabled (expiration: 0), because an unused token then lives forever and so must its pepper. That's documented on the property itself rather than left for an operator to find out.Cost
One extra query per configured previous pepper, and only for a token that hasn't been used since the rotation. An instance not mid-rotation has an empty keyring and does no extra work — asserted by
doesNotRetryPreviousPeppersWhenTheCurrentOneMatches, where the keyring stub going unread is half the point.Also
A current pepper containing a comma is now rejected at startup, rather than at the rotation where it would silently split into two wrong peppers — by which point the tokens hashed with it are unreachable and the mistake looks like data loss.
Verification
./gradlew test --tests '*AccessToken*'— 46 tests pass (--rerun-tasks), including 11 newAccessTokenConfigTestcases (binding, ordering, dedup, current-pepper removal, comma rejection, and both warning paths) and 5 new rotation cases inAccessTokenServiceTestspotlessCheckclean on every touched fileAccessTokenConfigTestneeds.withInitializer(... setConversionService(ApplicationConversionService.getSharedInstance()))— a bareApplicationContextRunnerhas no conversion service, so neither theDurationproperties nor the comma separated list bind without it. Worth knowing for the next config test.🤖 Generated with Claude Code