Skip to content

Preserve legacy encrypted stores - #6711

Merged
jhrozek merged 1 commit into
mainfrom
backwars-compatible-secrets
Sep 25, 2026
Merged

jhrozek merged 1 commit into
mainfrom
backwars-compatible-secrets

Conversation

@jhrozek

@jhrozek jhrozek commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Routine secret-store operations previously rewrote legacy encrypted stores to the current on-disk format as a side effect of opening them, silently upgrading the encryption/KDF parameters. This breaks compatibility for older ToolHive processes that share the same secrets file, since they can no longer read the upgraded format.

  • Preserve the observed file format for ordinary mutations instead of rewriting it on open
  • Require an explicit, confirmed protection upgrade (thv secret upgrade-protection) to migrate a store to Argon2id
  • The upgrade path verifies the replacement store before committing, and restores the original encrypted bytes if verification fails

Fixes #6710

Type of change

  • Bug fix

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)

Changes

File Change
pkg/secrets/encrypted.go Preserve on-disk format on mutation; add verified upgrade path
pkg/secrets/factory.go Wire up format detection and upgrade-protection support
cmd/thv/app/secret.go Add thv secret upgrade-protection command
pkg/secrets/encrypted_test.go Cover format preservation and upgrade verification/rollback
test/e2e/cli_secrets_scoped_test.go E2E coverage for scoped secrets
docs/arch/04-secrets-management.md, docs/cli/* Document the new command and behavior

Does this introduce a user-facing change?

Yes — adds a new thv secret upgrade-protection CLI command. Existing stores are no longer silently rewritten to a newer format on ordinary use.

@github-actions github-actions Bot added the size/L Large PR: 600-999 lines changed label Sep 24, 2026
Comment thread pkg/secrets/encrypted.go Dismissed
@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 60.93750% with 50 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.24%. Comparing base (da92423) to head (ef77788).

Files with missing lines Patch % Lines
pkg/secrets/factory.go 0.00% 33 Missing ⚠️
pkg/secrets/encrypted.go 81.42% 13 Missing ⚠️
pkg/lockfile/cleanup.go 83.33% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6711      +/-   ##
==========================================
- Coverage   79.26%   79.24%   -0.03%     
==========================================
  Files         800      800              
  Lines       80936    80991      +55     
==========================================
+ Hits        64154    64178      +24     
- Misses      16777    16808      +31     
  Partials        5        5              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@JAORMX JAORMX mentioned this pull request Sep 25, 2026
2 of 10 tasks

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The compatibility direction here makes sense: ordinary access should not silently rewrite shared state into a format that existing processes cannot use. Keeping legacy stores in their observed format is a reasonable immediate containment fix, while new stores continue using Argon2id.

The requirement is continued access, not manual migration for its own sake. Automatic migration is fine where supported existing consumers can still read and write the resulting store. Reading old files with the new binary is not sufficient evidence of backward compatibility, and a confirmation prompt does not make a breaking cutover compatible.

Requesting changes for the safety issue and acceptance-criteria gaps detailed inline:

  • The existing lock-file unlink lifecycle permits overlapping cross-process critical sections. With format-preserving writes, that can undo a successfully verified protection upgrade; rollback can also overwrite a concurrent update. The lock defect is inherited, but the upgrade depends on fixing it.
  • The failed-upgrade test fails at lock creation before reaching migration. It does not establish verification/rollback behavior.
  • The tests do not cover the reported old-process/new-CLI sharing scenario. The legacy-decryption helper and two current-version managers are useful, but do not meet that cross-version requirement.

Please also strengthen the operational guidance before shipping:

  • Put the pre-v0.50 compatibility boundary and the instruction to stop/upgrade other installations and running servers, proxies, and workloads in the actual confirmation (cmd/thv/app/secret.go:397–398), not only in --help.
  • Explicitly document recovery for stores already automatically converted: preserve the current secrets file and keyring password, upgrade every accessing installation to a format-aware version, and restart old processes. Replacing an executable does not update a running process. Installing this fix alone will not restore a pre-v0.50 process's access to an already-converted store. Do not recommend deleting the store or resetting the keyring for this mismatch.

Preserving legacy compatibility intentionally retains the weaker SHA-256 password derivation until protection is upgraded. That is a real security trade-off, not simply a false-positive warning. Keep already-upgraded stores protected; I would not expand this fix into an automatic downgrade or dual-store workaround.

Reviewed commit 381efe4bbfe15803a7b46db595ddb85baef81dc3. Static source/history and test-source review only. No tests or builds were run; the concurrency finding is statically traced, not dynamically reproduced.

Comment thread pkg/secrets/encrypted.go Resolved
Comment thread pkg/secrets/encrypted_test.go Resolved
Comment thread pkg/secrets/encrypted_test.go Resolved
@jhrozek

jhrozek commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

Re: finding #3 (no genuine cross-version test) — re-ran the manual cross-version repro against the final code (including the lock-fix from finding #1), this time with real compiled binaries from the actual historical commits, not just current-version managers:

== 1. legacy binary (pre-Argon2id, d9447746b) creates a store ==
OK set
-- header bytes --
 64 c8 36 ff 4b 4a d4 31 aa fd 7d ee de b5 76 7b

== 2. buggy binary (9fb4f660d, has #6710) reads it ==
OK got: supersecret
-- header bytes after buggy read --
 54 48 56 53 45 43 01 49 99 ad ff 15 62 3f 9e d4

== 3. legacy binary tries to read again (bug reproduces) ==
ERROR opening: unable to decrypt secrets file: cipher: message authentication failed
(failed as expected: old process broken by silent upgrade - this is #6710)

== 4. legacy binary creates a fresh store ==
OK set

== 5. FIXED binary does an ordinary get ==
OK got: supersecret
-- header bytes after fixed read (must be unchanged): confirmed unchanged --

== 6. legacy binary reads again (must still succeed) ==
OK got: supersecret

== 7. FIXED binary does an ordinary write too (must still preserve legacy format) ==
OK set
(legacy binary reads the fixed binary's write fine: OK got: value2)

== 8. explicit upgrade-protection via fixed binary ==
OK upgraded
-- header now THVSEC-framed --

== 9. fixed binary can still read everything post-upgrade ==
OK got: supersecret
OK got: value2

== 10. legacy binary now correctly fails (explicit, user-confirmed migration) ==
ERROR opening: unable to decrypt secrets file: cipher: message authentication failed
(expected: legacy can no longer read the upgraded store)

== 11. buggy (intermediate) binary can still read the upgraded store ==
OK got: supersecret
(confirmed forward-compatible with the intermediate version)

== 12. re-running upgrade-protection on an already-upgraded store is a safe no-op ==
OK upgraded
OK got: supersecret

This confirms, against the real pre-fix binaries rather than two current-version managers: the bug reproduces exactly as reported, the fix stops it for ordinary access, the explicit upgrade-protection path behaves as documented, and the intermediate (buggy) version remains forward-compatible with an explicitly-upgraded store.

This was run manually (three binaries built from d9447746b/9fb4f660d/HEAD via git worktree, driven directly against pkg/secrets.NewEncryptedManager) rather than landed as an automated CI test, since building and running historical-commit binaries isn't practical to keep running on every commit. Happy to discuss whether a lighter-weight automated version of this is worth adding as a follow-up.

@jhrozek
jhrozek force-pushed the backwars-compatible-secrets branch from 381efe4 to 11694b0 Compare September 25, 2026 09:09
@jhrozek
jhrozek marked this pull request as ready for review September 25, 2026 09:09
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Sep 25, 2026
@jhrozek

jhrozek commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — went through each point:

  • Lock race: confirmed it independently with a reproducer (real gofrs/flock, 16 goroutines Ă— lock/increment/release, showed lost updates before the fix). Fixed in pkg/lockfile: acquisition now verifies the locked file's inode still matches what the path resolves to and retries if it's stale/orphaned, and release unlinks before unlocking. Added a regression test that fails hard on the old code and passes on the new one.
  • The failure test didn't reach the code it claimed to: fixed by pre-creating the .lock file so the test's directory-permission trick blocks the actual install step instead of lock acquisition. Also split UpgradeProtection into installUpgrade/verifyUpgradeOrRollback and added a test that installs for real, corrupts the file on disk, then calls the verify/rollback step directly — that's the only deterministic way to hit that branch without adding a test-only hook.
  • Cross-version coverage: re-ran the repro with real binaries built from d9447746b (pre-Argon2id) and 9fb4f660d (the buggy intermediate version) against the final code — results posted above. Didn't wire this into CI since building historical-commit binaries per run isn't practical, but open to a lighter-weight version if you think it's worth it.
  • Confirmation prompt now carries the actual warning (irreversibility + stop/upgrade other installations) instead of leaving it in --help only.
  • Added a "Recovering from the pre-fix silent conversion" section to the secrets management doc covering stores already converted by Preserve legacy encrypted stores until users opt in to Argon2id migration #6710 before this fix — keep the file and password, upgrade and restart every installation, don't delete/reset.

Ready for another look.

@jhrozek
jhrozek requested a review from JAORMX September 25, 2026 09:10
Comment thread pkg/lockfile/cleanup.go Dismissed
JAORMX
JAORMX previously approved these changes Sep 25, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, Jakub. The revised failure/rollback coverage, the warning in the actual confirmation prompt, and the recovery instructions address those review points. The historical-binary results are useful evidence for the format-compatibility fix; I don't think building old revisions on every CI run needs to block this. Keeping the small manual harness available would be a useful follow-up.

Approving the compatibility fix at 11694b0. It stops ordinary access from silently cutting older consumers off, without deliberately downgrading already-upgraded stores. The new inode check also addresses the normal acquire/release race for upgraded WithFileLock callers.

Non-blocking follow-up: the cleanup paths still need attention. CleanupAll and CleanupStaleLocks still unlock before unlinking (pkg/lockfile/cleanup.go:51–55,180–184). A waiter can acquire and pass the inode check between those steps, then have its lock pathname removed while another process locks a replacement inode. Startup stale-lock cleanup includes the secrets-store directory, so this isn't just an unused helper. Older binaries retaining the old release behavior are another limit on mixed-version concurrency guarantees.

This is an inherited locking problem remaining outside the normal-path fix, rather than a new regression that should hold up stopping silent store conversion. Please track cleanup/lock-lifecycle hardening separately, and avoid describing the current change as eliminating every cross-process locking race.

The outstanding lint and CodeQL findings still need to be cleared or appropriately triaged before merge; this approval does not waive those checks.

Static review only. I inspected the updated code and test sources and read your reported historical-binary results; I did not run tests or builds.

Routine upgrades previously rewrote legacy encrypted stores when opened, breaking older ToolHive processes that shared the same secrets file.

Preserve the observed file format for ordinary mutations and require an explicit confirmed protection upgrade for Argon2id migration. The upgrade verifies the replacement and restores the original encrypted bytes when verification fails.

Fixes #6710
@jhrozek

jhrozek commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

Fixed the lint failures (gci formatting in the test file, a line over the length limit in printSecretsFileHint) and pushed.

For the two CodeQL findings: dismissed both, with the same rationale already applied elsewhere in this repo -

  • go/weak-sensitive-data-hashing on the legacy SHA-256 branch: same pattern as the already-dismissed alert feat: enhance go:// protocol to support local paths #487 on this file, it's the deliberate compatibility tradeoff this PR is about.
  • go/path-injection on lockMatchesPath's os.Stat(lockPath): same internally-constructed path already used unguarded a few lines away in this file, matches the shape of the many other go/path-injection alerts already dismissed false-positive across the codebase.

For the cleanup-path follow-up: opened #6713 to track CleanupAll/CleanupStaleLocks still doing unlock-before-unlink, as you flagged. Agreed this is inherited scope, not something this PR needs to close out, and I won't describe this change as closing every cross-process lock race - just the one on the normal WithFileLock path this PR's UpgradeProtection depends on.

@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Sep 25, 2026
@jhrozek

jhrozek commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

@JAORMX lint and the two CodeQL findings are cleared now (dismissed with justification, see above), and #6713 is filed for the cleanup-path follow-up. Should be ready for a final look whenever you have time.

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving at ef77788. I checked the delta from the previously approved revision: only the two formatting fixes changed, with no behavioral changes.

The failure/rollback coverage, confirmation warning, recovery guidance, and reported historical-binary compatibility checks address the substantive feedback for this fix. The remaining inherited cleanup-lock race is tracked separately in #6713 and remains a non-blocking follow-up.

Lint and CodeQL are now passing; the alert dispositions explicitly record the legacy-hashing trade-off and the path-alert rationale. Remaining required CI checks should pass before merge.

Static review only; no local tests or builds run.

@jhrozek
jhrozek merged commit 90e29fc into main Sep 25, 2026
47 checks passed
@jhrozek
jhrozek deleted the backwars-compatible-secrets branch September 25, 2026 10:33
@github-actions github-actions Bot mentioned this pull request Sep 25, 2026
2 tasks
suantea added a commit to suantea/toolhive that referenced this pull request Oct 11, 2026
Reorder CleanupAll and CleanupStaleLocks to remove the lock file before
unlocking to avoid the race described in the review of stacklok#6711. This
prevents the path-to-inode reuse window where a waiter could acquire a
stale inode after we release.

Addresses stacklok#6713 (follow-up to stacklok#6711).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Preserve legacy encrypted stores until users opt in to Argon2id migration

3 participants