Refuse to store a provider key in plaintext (fail closed on write, tolerant on read) - #2126
Conversation
… write
ProviderKeyProtector.Protect() returned the plaintext unchanged whenever no
master key was configured:
var key = masterKeyProvider.GetMasterKey();
if (key is null) return plaintext; // encryption disabled → passthrough
so an unconfigured deployment PERSISTED RAW PROVIDER KEYS into node content.
Nothing failed and nothing logged at the call site, and
ProviderKeyEncryptionTest stayed green because it configures a master key — the
degradation only occurred where nobody was testing. Found 2026-08-24 with a live
OpenRouter key sitting in cleartext in Provider/OpenRouter, readable by anyone
holding read on that namespace.
A provider key is the one value in that record that must never be written in the
clear, so a missing master key is a CONFIGURATION FAULT, not a degraded mode.
Protect() now throws, naming the missing config key AND the two supported ways to
avoid storing a literal at all (ModelDefinition.ApiKeySecretRef, or the
provider's {section}:ApiKey). A refusal that does not say what to do next is how
the passthrough survived unnoticed.
🚨 THE ASYMMETRY IS DELIBERATE. Unprotect() stays a passthrough: instances
already hold keys written in the clear by the old behaviour, and refusing on READ
would take them down on upgrade for data the platform itself produced. Fail on
the way IN, tolerate on the way OUT — new plaintext is impossible, existing
installations keep working while they migrate.
Also note where the fix does NOT belong: ModelProviderService.Protect() guards
`protector is null`, but IProviderKeyProtector is registered unconditionally
(TryAddSingleton in LanguageModelNodeType), so that branch is dead. The first
attempt patched it there and the new test caught it by not throwing — the
fallback is one layer down, in the protector itself.
ProviderKeyNoPlaintextTest pins the unconfigured deployment: storing a key
throws, the message names the fault, and it never echoes the key it refused. A
keyless provider (Copilot, local Claude Code CLI) stays valid — null/empty is
returned before the master-key check. ProviderKeyEncryptionTest still passes,
which is what proves the configured path is untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens provider credential storage by ensuring provider keys are never persisted in plaintext when a deployment is missing the configured master key, while keeping reads tolerant to avoid breaking upgrades that already contain legacy plaintext values.
Changes:
- Update
ProviderKeyProtector.Protect()to fail closed (throw) when asked to protect a non-empty, untagged key but no master key is configured. - Add a new regression test covering the “no master key configured” deployment case and asserting the refusal does not echo the secret.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| test/MeshWeaver.AI.Test/ProviderKeyNoPlaintextTest.cs | Adds a regression test for refusing to store non-empty provider keys without a master key configured. |
| src/MeshWeaver.AI/ProviderKeyProtector.cs | Changes provider-key protection to throw (fail closed) when no master key is configured, while keeping Unprotect() tolerant for legacy plaintext. |
Suppressed comments (2)
test/MeshWeaver.AI.Test/ProviderKeyNoPlaintextTest.cs:64
- The test name implies the protector is missing, but this fixture still registers IProviderKeyProtector; the scenario is “no master key configured”. Renaming the test makes the intent match the actual setup.
[Fact]
public async Task WithoutAProtector_StoringAKey_IsRefused_NotSilentlyStoredInPlaintext()
{
test/MeshWeaver.AI.Test/ProviderKeyNoPlaintextTest.cs:89
- This test claims keyless providers are still allowed without a protector, but it only asserts ModelProviderService can be resolved. To actually pin the “null/empty key is a valid write” requirement, create a provider with apiKey:null and assert the stored ApiKey remains null (and no exception is thrown).
[Fact]
public void AKeylessProvider_IsStillAllowed_WithoutAProtector()
{
// Keyless providers (GitHub Copilot, local Claude Code CLI) legitimately carry no key.
// Refusing those would break them, so null/empty must stay a valid write.
var service = Mesh.ServiceProvider.GetRequiredService<ModelProviderService>();
Assert.NotNull(service); // constructing the service must not require a protector
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Test Results (shard 3) 9 files 9 suites 5m 50s ⏱️ Results for commit a742667. ♻️ This comment has been updated with latest results. |
Test Results (shard 5) 9 files 9 suites 7m 13s ⏱️ Results for commit a742667. ♻️ This comment has been updated with latest results. |
Test Results (shard 0)624 tests 620 ✅ 17m 22s ⏱️ Results for commit a742667. ♻️ This comment has been updated with latest results. |
Test Results (shard 4)2 170 tests 2 166 ✅ 14m 35s ⏱️ Results for commit a742667. ♻️ This comment has been updated with latest results. |
Test Results (shard 2)2 908 tests 2 805 ✅ 7m 44s ⏱️ Results for commit a742667. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)2 048 tests 1 855 ✅ 8m 58s ⏱️ Results for commit a742667. ♻️ This comment has been updated with latest results. |
Test Results 53 files + 53 53 suites +53 1h 1m 44s ⏱️ + 1h 1m 44s Results for commit a742667. ± Comparison against base commit 72d9f64. ♻️ This comment has been updated with latest results. |
Six ModelProviderServiceTest cases went red on the previous commit. They were
right to: each stores real key material with NO master key configured, which is
now a refusal rather than a silent plaintext write.
Two distinct problems, both worth naming:
1. The fixture configured no master key. Any test that stores a provider key
needs one, exactly as ProviderKeyEncryptionTest already does — otherwise it is
exercising a path the platform no longer permits. Added.
2. TWO ASSERTIONS WERE PINNING THE LEAK ITSELF:
cfg.ApiKey.Should().Be("sk-ant-TEST-1234");
... ?.ApiKey == "sk-new" / cfg.ApiKey.Should().Be("sk-new");
That asserts the key is stored IN THE CLEAR. Those two would have passed
happily on the plaintext passthrough that put a live OpenRouter key into
production node content — a test can only protect an invariant it actually
states, and these stated the opposite one. They now assert the security
property: the value at rest is enc:v1:-tagged ciphertext and does NOT contain
the literal.
9 passed, 0 failed across ModelProviderServiceTest, ProviderKeyEncryptionTest
and the new ProviderKeyNoPlaintextTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The one failing test asserted the behaviour this PR deliberately removes: NoMasterKey_Passthrough required Protect to return the key unchanged when no master key is configured. That passthrough IS the defect — it persisted a raw provider key, and was found in production with a live OpenRouter key in cleartext in Provider/OpenRouter node content. It now asserts the refusal, and that the error names the setting to configure, because an unactionable throw sends the operator hunting — which is how the silent passthrough survived. Reads stay tolerant in the same state, asserted in the same test: fail on the way IN, tolerate on the way OUT, so a deployment already holding legacy plaintext keeps working after an upgrade and simply cannot write again until it is configured. Both review findings, and the second was subtler than a typo: - Protect's <returns> still promised "the original value when encryption is disabled". That case no longer exists; the doc now says so explicitly and documents the exception. - ProviderKeyNoPlaintextTest said "NO master key, therefore no IProviderKeyProtector". The protector is registered unconditionally — what is missing is the master key. The distinction matters because "no protector" suggests registering one, and the actual fix is to configure the key. The two test NAMES carried the same wrong model (WithoutAProtector…), so they are renamed too: a misleading name outlives a misleading comment. Not changed, and worth stating: with no master key configured a deployment now cannot store a provider key at all. That is this PR's stated intent, not a side effect — keyless providers (Copilot, local Claude Code CLI) are unaffected because null/empty returns before Protect looks for a key. 11 ProviderKey tests green; -c Release -warnaserror clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moves the refusal from ProviderKeyProtector.Protect() to ModelProviderService.Protect(), and guards on the MASTER KEY rather than on the protector instance. Two corrections to the previous commits, both found by tests rather than by reading: 1. `protector is null` is a DEAD branch — IProviderKeyProtector is registered unconditionally (TryAddSingleton in LanguageModelNodeType). The real passthrough is the null master key one layer down. Guarding on IMasterKeyProvider.GetMasterKey() is what actually closes it. 2. ProviderKeyProtector.Protect() must STAY a passthrough. The seeding path (ProviderCredentialSeeder) deliberately depends on it: it detects the unprotected case itself and reports ProviderSeedOutcome.RefusedUnprotected — a structured, observable refusal. Throwing down there converts that graceful outcome into an exception, which is why the previous commit turned ProviderCredentialSeedWithoutMasterKeyTest red. So the invariant now holds on both paths, each in the shape that path can carry: the SEEDER refuses with an outcome, the INTERACTIVE write refuses with an exception naming the missing config key and the two ways to avoid a literal. Tests: 24 passing across ModelProviderServiceTest, ProviderKeyEncryptionTest, the new ProviderKeyNoPlaintextTest and the ProviderCredentialSeed suites.⚠️ ProviderCredentialSeedWithoutMasterKeyTest.NoMasterKey_RefusesToSeed fails on CLEAN main as well — verified by stashing this change and re-running it. It is PRE-EXISTING and unrelated; this branch neither causes nor fixes it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eder Correction to two earlier commits on this branch. WHAT WENT WRONG. The throw was moved out of ProviderKeyProtector.Protect() and into ModelProviderService, but the `git checkout --` that removed it was undone by a later `git stash pop`, so the throw was still in the tree. It kept turning ProviderCredentialSeedWithoutMasterKeyTest red: the seeder relies on Protect() passing plaintext through so it can DETECT the unprotected case and return ProviderSeedOutcome.RefusedUnprotected — a structured refusal. An exception down there converts that graceful outcome into a crash. Worse, it was then mis-diagnosed as pre-existing. The check that "proved" it — stash the change, re-run — was run with `--no-build`, so it exercised the STALE binary and reproduced the same failure with the source removed. That looked like confirmation and was the opposite: a control that cannot fail proves nothing. Rebuilding shows the test passes without the throw. NET EFFECT: ProviderKeyProtector is byte-identical to main again. The guard lives only in ModelProviderService.Protect() — the interactive write path, which has no outcome to return and so refuses loudly — and it keys on IMasterKeyProvider.GetMasterKey() rather than `protector is null`, which is a dead branch (registered unconditionally by TryAddSingleton). 25 passing across ProviderCredentialSeed (both fixtures), ModelProviderServiceTest, ProviderKeyEncryptionTest and ProviderKeyNoPlaintextTest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Merging main broke this branch's test compile in two places, both because main
moved the types out from under it:
* `Memex.Portal.Shared.Models` no longer exists — `ModelProviderService` now
lives in `MeshWeaver.AI.Portal` (the portal split). Dead using removed, right
one added.
* `IProviderKeyProtector` moved to `MeshWeaver.Mesh.Security`, so the cref in
the class doc could not resolve — CS1574, an ERROR under -warnaserror.
Compile only. No behaviour touched, and the revert-check still shows exactly this
PR's four files.
🚨 This does NOT make the PR green, and deliberately so. It still fails
`ProviderKeyProtectorTest.NoMasterKey_RefusesToWrite_ButStillReads`, and that
failure is PRE-EXISTING — it predates this merge and is the PR's real open
question. See the PR comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Merged What I fixed (compile only)Main had moved types out from under the test:
Revert-check still shows exactly this PR's four files. No behaviour touched. The real blocker — the PR contradicts itselfThe test asserts that
So the mechanism was removed and the test asserting it was left in place. The PR currently neither refuses plaintext nor passes. That is not merge damage — it has been true since 2026-08-24. Why this matters more than a red testThe test's own comment states the stakes exactly:
So the fix was written, it worked, and it was reverted because refusing to write broke the seeder. That is the actual design question this PR is stuck on, and it needs deciding rather than patching:
🚨 I have deliberately not chosen. Option 2 done carelessly recreates the vulnerability with extra steps, and options 1 and 3 change how deployments are bootstrapped. Whichever is picked, the test should be updated in the SAME change so the code and its assertion never disagree again. And this fixes only the write sideWorth restating so it is not lost: even green, this PR stops new plaintext keys. It does not un-leak the existing ones. |
…2126) The branch has neither refused plaintext nor passed since 2026-08-24: the protector's throw was reverted "because it was breaking the seeder", and the test asserting that throw was left standing. This resolves it in the seeder's favour of the SECURITY intent, not the other way round. ## Why the seeder broke, and why it is not an exemption ProviderCredentialSeed did not want a plaintext passthrough. It wanted a CAPABILITY QUESTION — "can this deployment encrypt?" — and got the answer by calling Protect and inspecting what came back, which only worked because Protect degraded. It already refuses correctly (ProviderSeedOutcome.RefusedUnprotected, logged at Error, node left keyless); it just discovered the state from a fallback. A throw there faults the whole boot seed instead of reporting ONE provider's refusal, and its own fixture (ProviderCredentialSeedWithoutMasterKeyTest) is deliberately an unconfigured deployment — so option 1, "give the seeder a master key", cannot apply to the very path that has to keep working without one. So the seeder now asks IMasterKeyProvider BEFORE protecting (ProtectOrNothing), and Protect refuses unconditionally. That is not an exemption: null there is a decision NOT to write, there is no branch from it to a persist, and the produced bytes are still checked for the enc: tag before the write. ## The passthrough was in five more places, all silent IProviderKeyProtector also carries a GitHub PAT, the plugin registry's sync-token signing key, an installation's registry credential and the Entra EA refresh token. Every one of them wrote `protector?.Protect(x) ?? x` on top of a degrading Protect — the same leak, unreported. They now resolve the protector as required and let the refusal through; storing a credential that cannot be encrypted is not a fallback anyone chose, it is what nobody noticed. ModelProviderService drops its duplicate probe and delegates, so the rule lives in one place. ModelProviderLayoutAreas.SaveKey protects inside the chain, so the refusal lands on the subscription's onError instead of escaping an OnNext. ## Option 1 where it does apply: the test environment MeshWeaver.Fixture now configures a test master key for every mesh test. The DEFAULT test environment was the misconfigured one, which is exactly why this survived: the encryption test configured a key, so the degradation only ever happened where nobody was testing. It lives in ServiceSetup rather than test/appsettings.json because eleven test projects shadow that file. Fixtures that must exercise the unprotected deployment register their own IConfiguration and still do. ## Ratchet NoPlaintextCredentialFallbackGuard fails on any Protect call site that carries a `??`, an `is null ?` or a `?.Protect(` — the shapes that shipped. It caught the first version of the seeder's own fix, which is the point. Verified: AI.Test provider-key/seed/service 36/36 (including the previously-red NoMasterKey_RefusesToWrite_ButStillReads), Auth.Test sync-token + instance registration 22/22, GitSync.Test 187/187, PluginCatalog.Test ModuleAutoDiscovery 14/14, Graph.Test registration 1/1, Documentation.Test 70/70. Release -warnaserror clean on every touched project. 🚨 This stops NEW plaintext. It does not un-leak the existing keys — Provider/_Policy is publicRead and the values are in version history, so rotation is required regardless and is the operator's action. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`current.Content is ModelProviderConfiguration cfg` returns the node UNCHANGED whenever the content arrived as a degraded JsonElement — a save that silently does nothing, on the one control whose entire job is to store the key. Same trap-door class as the plaintext passthrough this PR removes: no exception, no log, and it looks like it worked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ModelProviderSetup.md said "If no master key is configured, Protect() is a passthrough (plaintext)" and the /provider-keys skill printed the exact `protector is null ? newKey : protector.Protect(newKey)` line this PR deletes — teaching the shape that leaked the key, in a Skill node the portal serves to agents. Both now state the refusal, and the skill's sample carries the two rules the guard enforces: no fallback around Protect, and ContentAs over an is-cast. Verified on a --no-incremental rebuild (content/ai/**/*.md is an EmbeddedResource of MeshWeaver.AI, so --no-build after a .md edit tests the OLD bytes): Documentation.Test 70/70, AI.Test skill-catalog + provider-key 32/32. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…laintext deploy/.env.example told operators to "leave blank only for throwaway/dev (then keys are stored as plaintext)" — the exact instruction that produced the leak. Blank now means the install refuses to store any credential, and both files say which ones and why. values.aks.yaml's comment is already accurate and is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
content/ai is a mirror of the private plugins pack, and every edit here has to restate its reconciliation point. This entry is PackAbsent — the skill exists only in core, so there is no master to carry the edit to; only the `core` hash moves. Caught by CI because my local run filtered to the provider-key tests and never executed the guard. Verified this time on the WHOLE suite: AI.Test 1448 passed, 3 skipped, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🚨 Rotation is required regardless, and it is not this PR
This stops NEW plaintext credentials. It does not un-leak the existing ones.
Provider/_Policyhas
publicRead: true, and every value written in the clear is also in the node's versionhistory — so a green merge here is not "the leak is handled". Any credential that was stored
unencrypted must be rotated at its source, and that is the operator's action, not a code change.
The contradiction this PR was stuck on
Since 2026-08-24 the branch neither refused plaintext nor passed:
ProviderKeyProtectorTest.NoMasterKey_RefusesToWrite_ButStillReadsassertedProtect("plain")throws.The mechanism was removed and the assertion asserting it was left standing.
Why the seeder broke — and why it is not exempted
ProviderCredentialSeed(the boot-time step that copies{Section}:ApiKeyfrom a deployment'sconfiguration onto its
Provider/{name}node) never wanted the passthrough. It wanted acapability question — "can this deployment encrypt?" — and answered it by calling
Protectandinspecting what came back, which only worked because
Protectdegraded:So it already refuses correctly —
ProviderSeedOutcome.RefusedUnprotected, logged at Error, nodeleft keyless. It just discovered the state from a fallback. Making
Protectthrow turns that intoan exception inside a sequential per-provider pipeline, which aborts the seed for every remaining
provider instead of reporting one refusal.
The choice, plainly
Option 1 (give the seeder a master key) — chosen where it applies, but it cannot be the whole
answer. The seeder's own fixture,
ProviderCredentialSeedWithoutMasterKeyTest, is anunconfigured deployment, and unconfigured deployments exist in production — that is the reported
leak. The refusal path must keep working without a master key, so no amount of configuration
removes the need for the seeder to decide.
Where option 1 does apply is the test environment, and that turned out to be the real finding:
MeshWeaver.Fixturenow configures a test master key for every mesh test. The default testenvironment was the misconfigured one — which is precisely why this survived. As the original PR
body put it,
ProviderKeyEncryptionTeststayed green because it configures a master key, so thedegradation only happened where nobody was testing. It lives in
ServiceSetuprather thantest/appsettings.jsonbecause eleven test projects ship their own file that shadows the sharedone. Fixtures that must exercise the unprotected deployment register their own
IConfigurationandstill do.
Option 2 (stop seeding secrets) would delete a convergence the platform needs: measured on
memex.systemorph.com,Provider/Anthropicwas created keyless on 2026-08-14,Anthropic__ApiKeywas configured after it, and the node stayed keyless until a human pasted the key in on 08-21. The
seeder exists to close that gap; removing it re-opens a real operational defect to fix a different one.
Option 3 (a named exemption) was not needed — so it was not taken. The seeder asks
IMasterKeyProviderbefore protecting:nullfeedsIsProtected(...)→RefusedUnprotected. There is no branch from here to a persist,the plaintext is never returned, and the produced bytes are still checked for the
enc:tag beforethe write. The refusal in
Protectis therefore absolute, with no exempted caller.The passthrough was in five more places, all silent
This is the part the PR title understates.
IProviderKeyProtectoris not provider-key-only — it alsocarries a GitHub PAT, the plugin registry's sync-token signing key, an installation's
registry credential, and the Entra EA refresh token. Every one of them wrote
protector?.Protect(x) ?? xon top of a degradingProtect— the same leak, in four moresubsystems, equally unreported. Leaving them would have shipped a PR titled "refuse to store a key in
plaintext" that refused in one place out of six.
ProviderCredentialSeedIMasterKeyProvider, reportsRefusedUnprotectedModelProviderService.ProtectModelProviderLayoutAreas.SaveKeyprotector is null ? newKey : …onError, not out of anOnNextGitHubCredentialService.Protectprotector is null ? plaintext : …SyncTokenSigningKeyService.Protectprotector?.Protect(e) ?? eInstanceAutoRegistrationServiceprotector?.Protect(k) ?? kConfigMasterKeyProvider's "no key configured" line moves from Information to Error. It usedto read "keys stored as plaintext" at Information — the log line that described the leak and was
never acted on.
🚨 The asymmetry is unchanged and deliberate
Unprotect()stays a passthrough. Instances already hold keys written in the clear by the oldbehaviour; refusing on read would take them down on upgrade for data the platform itself produced.
Fail on the way IN, tolerate on the way OUT.
Operational consequence, stated plainly
A deployment with no
Ai:KeyProtection:MasterKeycan no longer store a credential: entering aprovider key fails with an actionable message, a GitHub credential cannot be saved, a registry can
no longer mint a sync-token signing key, and EA token rotation fails. That is the intended reading of
"a missing master key is a configuration fault, not a degraded mode" — every one of those paths was
previously writing a live secret in cleartext.
deploy/helm/values.yamlandvalues.aks.yamlalreadycarry the key; it is supplied out-of-band per environment.
Tests
ProviderKeyProtectorTest.NoMasterKey_RefusesToWrite_ButStillReads— the assertion that hasbeen red for two days, kept as-is and now green. It is the test that fails without the fix.
ProviderKeyNoPlaintextTest— refusal names the fault, never echoes the key; and the keylessboundary is now genuinely asserted (
null/""/ already-tagged still pass without a master key)rather than asserting a service is non-null.
ProviderKeyProtectorRegistrationTest— assertedUnprotect(Protect(x)) == x"whether or notthis host configures a master key", which a passthrough satisfies. Now asserts the stored form
is
enc:v1:ciphertext, which it cannot.NoPlaintextCredentialFallbackGuard(new ratchet) — fails on anyProtectcall site carrying??,is null ?or?.Protect(. It caught the first version of the seeder's own fix, whichis the point: the guard, not care, is what keeps the refusal from widening back.
Verified locally, Release
-warnaserrorclean on every touched project:MeshWeaver.AI.Test(provider-key / seed / service)MeshWeaver.Auth.Test(sync-token + instance registration)MeshWeaver.GitSync.TestMeshWeaver.PluginCatalog.Test(ModuleAutoDiscovery)MeshWeaver.Graph.Test(protector registration)MeshWeaver.Documentation.Test(all guards)What's New entry:
Category: Fix— and it says the rotation part out loud, for the same reason thisbody does.
🤖 Generated with Claude Code
One follow-up, in the sibling repo — deliberately not in this PR
MeshWeaver.Plugins→src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.cs:1717(
StoreHarnessCredential) carries the same shape this PR removes here:It compiles fine against this change (behaviour-only, no signature change) and it stops leaking as
soon as this merges — but the refusal would surface as an unhandled Blazor exception instead of
its own
"Couldn't store the credential: {ex.Message}"status, becauseProtectruns before theCreateOrUpdateNode(...).Subscribe(_, ex => …)that has the handler. The fix is the same one appliedto
ModelProviderLayoutAreas.SaveKey: protect inside the chain so the refusal lands on the existingonError. Platform first, then the dependent repo.