Repository navigation
feat(security): seal stored third-party credentials with DPAPI - #1295
Merged
github-actions[bot] merged 3 commits intoOct 15, 2026
Merged
github-actions[bot] merged 3 commits into
github-actions[bot] merged 3 commits into
Conversation
GitHub tokens, the Claude admin key and the Azure Foundry key were written as plaintext JSON under the user profile. A shared value-level envelope now seals them with DPAPI CurrentUser, migrating legacy plaintext on first read and keeping every other field readable. IDeviceCredentialStore.Save becomes SaveAsync so the Android pairing save no longer blocks. Recorded as proposed local ADR 0030. Decision taken without asking: seal every stored credential rather than accept plaintext under the profile; the alternative is named in the pull request and in ADR 0030. Refs #753 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 15, 2026
JSdotNet
marked this pull request as ready for review
October 15, 2026 18:07
…2026-10-15/753-credentials-at-rest Keeps both sides of the ADR list and related: conflicts (ADR 0030 beside main's ADR 0029/0031 and the Sync context's moved pairing anchors), and regenerates the packages.lock.json files main's #1293 committed, which the ProtectedData references this branch adds made stale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI failed The_window_can_be_narrowed_to_four_weeks with "Collection was modified": the pane now loads each region on its own, so a load was still adding to RecordingProductivityInsights.Scopes while the test enumerated it. The double now records under a lock and hands out a snapshot. Co-Authored-By: Claude Opus 5.5 <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.
Closes #753
Summary
Every stored third-party credential now goes through the OS-protected envelope. A shared
SecretEnvelope(DPAPI CurrentUser, value-leveldpapi:<base64>) seals the GitHub account and repository tokens ingithub.json, the ClaudeAdminApiKeyinclaude.jsonand the Azure FoundryApiKeyinazure-foundry.json; every non-secret field stays readable JSON. Legacy plaintext is migrated on first read; an envelope that cannot be opened reads as "no key".IDeviceCredentialStore.SavebecomesSaveAsync, so the Android pairing save no longer blocks. Recorded as proposed local ADR 0030 with the threat model (a bearer key that works from any machine vs. a file copied off it), keeping ADR 0012's loopback MCP token as the stated exception; R8, the crosscutting Authentication section andtech/desktop.mdare reconciled.Decision taken without asking (follow it, or ask for the alternative)
Question. How are GitHub, Claude and Azure Foundry credentials stored at rest: plaintext accepted under the user profile (Option A), or through the DPAPI / platform secure-store envelope (Option B)?
Chosen. Option B: every stored third-party credential goes through the OS-protected envelope (DPAPI CurrentUser on Windows; the three stores are desktop-only, so no Android work for them). Recorded as new local ADR 0030 (proposed). Applied as a value-level envelope on the four secret fields, so the JSON files stay legible. Legacy plaintext is migrated on first read, and an unopenable envelope reads as 'no key'. Also adds IDeviceCredentialStore.SaveAsync so the Android pairing save no longer blocks.
Alternative. Option A: state in an ADR 0012 amendment or new ADR that secrets under %LOCALAPPDATA% are protected by the OS profile and plaintext is accepted, with the threat model. Then add a doc comment to each of the three stores, as WorkspaceSettingsStore has for the MCP token.
Why the choice. Five pieces of repository evidence favour B. (1) Risk R8 in 11-risks-and-technical-debt.md already names 'use OS secure storage' as the mitigation for on-device external credentials. (2) The two credentials added most recently, the sync device credential and the spec-manager refresh token, both use DPAPI CurrentUser. DeviceCredentialStoreFactory and SpecManagerTokenStoreFactory both say a secret written in the clear is 'the one thing that would not be acceptable', and the spec-manager store chose in-memory over plaintext on non-Windows. (3) ADR 0012's plaintext argument is specific: the MCP token guards a loopback server for the same user, so encrypting it against that user defends nothing. A GitHub PAT, an Anthropic admin key or an Azure key is a bearer credential that works from any machine. DPAPI CurrentUser does defend against file copies (backups, disk images, diagnostic zips, other accounts, offline disk reads), and that asymmetry is the threat model. (4) The envelope helper already exists twice in the codebase, ProtectedData is centrally versioned, and CI tests run on windows-latest. (5) The value-level envelope keeps every structural test assertion and the hand-readable settings files intact, so the change stays confined to three stores and a handful of token-text assertions.
To follow the alternative instead. Following A instead would leave GitHubSettings.cs, ClaudeSettings.cs, AzureFoundrySettings.cs and the three csproj files unchanged apart from a doc comment on each store citing the ADR. It would drop the SecretEnvelope helper, the migration code and the flipped test assertions (RepositoryRegistrySplitTests would keep Assert.Contains("ghp_secret", local)). ADR 0030 would record A as chosen and B as the considered option, and R8 would be closed as accepted rather than mitigated. The IDeviceCredentialStore.SaveAsync part is independent of the choice and could stay either way.
Acceptance criteria
Testing
cd "D:/iswp/753-credentials-at-rest" && dotnet build Backlog.sln ; dotnet build Backlog.WithoutAppHeads.slnf --configuration Release -p:TreatWarningsAsErrors=true --no-incremental ; dotnet test --solution Backlog.WithoutAppHeads.slnf --no-build --configuration Release --max-parallel-test-modules 1 --parallel nonebuild.mjs --check;check-metadata.mjs: 0 blocking).Review
withexpression.What could not be proved
%LOCALAPPDATA%\Backlog\github.json,claude.jsonandazure-foundry.jsonare rewritten withdpapi:values. An older build reading a migrated file would send the envelope text as the key (downgrade hazard), and copying the files to another machine or profile now yields empty keys — re-entering them in Settings is the recovery.dpapi:values, Settings still shows each key configured, and GitHub calls, Claude usage and Foundry chat still work; (2) in the desktop web harness, theobj/local-development/*.settings.jsonfiles migrate andSeedLocalAzureFoundrySettingsstill recognises its marker key; (3) on the Android emulator, pairing still succeeds and survives a restart through the awaitedSecureStoragewrite.IDeviceCredentialStore.Saveis replaced bySaveAsync(the Android head implements it).Merge Danger
Door. One-way. The first start migrates the three credential files in place; a revert leaves
dpapi:values an older build cannot read (re-enter the keys). Decision record: local ADR 0030,proposed.Blast radius. consumers — every desktop install's stored GitHub, Claude and Azure Foundry credentials, and the Android head's pairing save.
Assumptions taken instead of asking
dpapi:+ base64 prefix. A value without the prefix is treated as legacy plaintext. No real GitHub, Anthropic or Azure key starts withdpapi:.Opened as a draft by an attended run of
delivery-schedule:schedule-issue-sweep(2026-10-15), told by the owner to decide without asking and to name the choice and the alternative.🤖 Generated with Claude Code