Skip to content

Refuse to store a provider key in plaintext (fail closed on write, tolerant on read) - #2126

Merged
rbuergi merged 16 commits into
mainfrom
fix/refuse-plaintext-provider-key
Aug 26, 2026
Merged

Refuse to store a provider key in plaintext (fail closed on write, tolerant on read)#2126
rbuergi merged 16 commits into
mainfrom
fix/refuse-plaintext-provider-key

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

🚨 Rotation is required regardless, and it is not this PR

This stops NEW plaintext credentials. It does not un-leak the existing ones. Provider/_Policy
has publicRead: true, and every value written in the clear is also in the node's version
history
— 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_ButStillReads asserted Protect("plain") throws.
  • The branch's most recent commit was "revert the protector throw for real — it was breaking the seeder".

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}:ApiKey from a deployment's
configuration onto its Provider/{name} node) never wanted the passthrough. It wanted a
capability question — "can this deployment encrypt?" — and answered it by calling Protect and
inspecting what came back, which only worked because Protect degraded:

var protectedKey = protector is null ? c.ApiKey : protector.Protect(c.ApiKey);
var outcome = Decide(current, hasConfiguredKey: true, protectionAvailable: IsProtected(protectedKey));

So it already refuses correctly — ProviderSeedOutcome.RefusedUnprotected, logged at Error, node
left keyless. It just discovered the state from a fallback. Making Protect throw turns that into
an 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 an
unconfigured 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.Fixture now configures a test master key for every mesh test. The default test
environment was the misconfigured one
— which is precisely why this survived. As the original PR
body put it, ProviderKeyEncryptionTest stayed green because it configures a master key, so the
degradation only happened where nobody was testing
. It lives in ServiceSetup rather than
test/appsettings.json because eleven test projects ship their own file that shadows the shared
one. Fixtures that must exercise the unprotected deployment register their own IConfiguration and
still do.

Option 2 (stop seeding secrets) would delete a convergence the platform needs: measured on
memex.systemorph.com, Provider/Anthropic was created keyless on 2026-08-14, Anthropic__ApiKey
was 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
IMasterKeyProvider before protecting:

private static string? ProtectOrNothing(
    IProviderKeyProtector? protector, IMasterKeyProvider? masterKeys, string? configuredKey)
{
    if (protector is null || masterKeys?.GetMasterKey() is null)
        return null;                       // a DECISION NOT TO WRITE, never a fallback
    return protector.Protect(configuredKey);
}

null feeds IsProtected(...)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 before
the write. The refusal in Protect is therefore absolute, with no exempted caller.

The passthrough was in five more places, all silent

This is the part the PR title understates. IProviderKeyProtector is not provider-key-only — it 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, in four more
subsystems, 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.

Call site Before Now
ProviderCredentialSeed probe via passthrough asks IMasterKeyProvider, reports RefusedUnprotected
ModelProviderService.Protect duplicated master-key probe + dead null branch delegates; one rule, one place
ModelProviderLayoutAreas.SaveKey protector is null ? newKey : … protects inside the chain → refusal lands on onError, not out of an OnNext
GitHubCredentialService.Protect protector is null ? plaintext : … required; refusal reaches the caller
SyncTokenSigningKeyService.Protect protector?.Protect(e) ?? e required — this key signs every sync token
InstanceAutoRegistrationService protector?.Protect(k) ?? k required

ConfigMasterKeyProvider's "no key configured" line moves from Information to Error. It used
to 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 old
behaviour; 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:MasterKey can no longer store a credential: entering a
provider 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.yaml and values.aks.yaml already
carry the key; it is supplied out-of-band per environment.

Tests

  • ProviderKeyProtectorTest.NoMasterKey_RefusesToWrite_ButStillReads — the assertion that has
    been 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 keyless
    boundary is now genuinely asserted (null / "" / already-tagged still pass without a master key)
    rather than asserting a service is non-null.
  • ProviderKeyProtectorRegistrationTest — asserted Unprotect(Protect(x)) == x "whether or not
    this 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 any Protect call site carrying
    ??, is null ? or ?.Protect(. It caught the first version of the seeder's own fix, which
    is the point: the guard, not care, is what keeps the refusal from widening back.

Verified locally, Release -warnaserror clean on every touched project:

Suite Result
MeshWeaver.AI.Test (provider-key / seed / service) 36 / 36
MeshWeaver.Auth.Test (sync-token + instance registration) 22 / 22
MeshWeaver.GitSync.Test 187 / 187
MeshWeaver.PluginCatalog.Test (ModuleAutoDiscovery) 14 / 14
MeshWeaver.Graph.Test (protector registration) 1 / 1
MeshWeaver.Documentation.Test (all guards) 70 / 70

What's New entry: Category: Fix — and it says the rotation part out loud, for the same reason this
body does.

🤖 Generated with Claude Code


One follow-up, in the sibling repo — deliberately not in this PR

MeshWeaver.Pluginssrc/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.cs:1717
(StoreHarnessCredential) carries the same shape this PR removes here:

var stored = protector is null ? credential : protector.Protect(credential);

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, because Protect runs before the
CreateOrUpdateNode(...).Subscribe(_, ex => …) that has the handler. The fix is the same one applied
to ModelProviderLayoutAreas.SaveKey: protect inside the chain so the refusal lands on the existing
onError. Platform first, then the dependent repo.

… 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>
Copilot AI lite review requested due to automatic review settings August 24, 2026 09:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread src/MeshWeaver.AI/ProviderKeyProtector.cs Outdated
Comment thread test/MeshWeaver.AI.Test/ProviderKeyNoPlaintextTest.cs
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Test Results (shard 3)

    9 files      9 suites   5m 50s ⏱️
2 002 tests 2 002 ✅ 0 💤 0 ❌
2 590 runs  2 590 ✅ 0 💤 0 ❌

Results for commit a742667.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Test Results (shard 5)

    9 files      9 suites   7m 13s ⏱️
1 614 tests 1 613 ✅ 1 💤 0 ❌
1 615 runs  1 614 ✅ 1 💤 0 ❌

Results for commit a742667.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Test Results (shard 0)

624 tests   620 ✅  17m 22s ⏱️
  7 suites    4 💤
  7 files      0 ❌

Results for commit a742667.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Test Results (shard 4)

2 170 tests   2 166 ✅  14m 35s ⏱️
   10 suites      4 💤
   10 files        0 ❌

Results for commit a742667.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Test Results (shard 2)

2 908 tests   2 805 ✅  7m 44s ⏱️
    9 suites    103 💤
    9 files        0 ❌

Results for commit a742667.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Test Results (shard 1)

2 048 tests   1 855 ✅  8m 58s ⏱️
    9 suites    193 💤
    9 files        0 ❌

Results for commit a742667.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Test Results

    53 files  +    53      53 suites  +53   1h 1m 44s ⏱️ + 1h 1m 44s
11 364 tests +11 364  11 059 ✅ +11 059  305 💤 +305  0 ❌ ±0 
11 955 runs  +11 955  11 650 ✅ +11 650  305 💤 +305  0 ❌ ±0 

Results for commit a742667. ± Comparison against base commit 72d9f64.

♻️ This comment has been updated with latest results.

@rbuergi
rbuergi enabled auto-merge August 24, 2026 10:11
rbuergi and others added 7 commits August 24, 2026 12:12
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>
rbuergi and others added 2 commits August 26, 2026 20:27
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>
@rbuergi

rbuergi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Merged main in and repaired the compile — but this PR is red for a REAL reason that predates my merge, and it is the reason the plaintext-key vulnerability is still open.

What I fixed (compile only)

Main had moved types out from under the test:

  • Memex.Portal.Shared.Models no longer exists — ModelProviderService now lives in MeshWeaver.AI.Portal (the portal split).
  • IProviderKeyProtector moved to MeshWeaver.Mesh.Security, so the class-doc cref no longer resolved — CS1574, an error under -warnaserror, not a warning.

Revert-check still shows exactly this PR's four files. No behaviour touched.

The real blocker — the PR contradicts itself

ProviderKeyProtectorTest.NoMasterKey_RefusesToWrite_ButStillReads   FAIL
  Expected value to be of type InvalidOperationException, but found <null>.

The test asserts that Protect("plain") throws when no master key is configured. The branch's own most recent commit is:

fix(ai): revert the protector throw for real — it was breaking the seeder

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 test

The test's own comment states the stakes exactly:

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.

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:

  1. Give the seeder a master key in dev/test — the refusal stays absolute, and "no master key" genuinely means misconfiguration everywhere.
  2. Make the seeder path an explicit, named exemption — narrow, loud, and asserted (never a silent passthrough, which is the defect).
  3. Stop seeding keys at all — seed providers without secrets and require the key to be supplied through the protected path.

🚨 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 side

Worth restating so it is not lost: even green, this PR stops new plaintext keys. It does not un-leak the existing ones. Provider/_Policy has publicRead: true, and the values are also in version history — so rotation has to happen regardless, and it is the first step, not the last.

rbuergi and others added 6 commits August 26, 2026 22:21
…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>
@rbuergi
rbuergi merged commit 05cd03e into main Aug 26, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants