The Self-Update Registry Credential

An installation on a non-ACR registry lists its platform tags with the mwi_ instance key it already holds for a plugin registry. WHICH plugin registry's key is a disclosure decision, and it is decided by DECLARED PAIRINGS β€” never by host equality, never by name resemblance, and never by "there is only one key, so use it".

🚨 Two different questions, and confusing them is how this page would mislead. The key is always SENT TO SelfUpdate:Registry β€” the container registry β€” because that is the host being listed. What the rule below decides is WHICH KEY: which of this installation's configured plugin-registry credentials is selected. The validator host is a credential source, never a destination; nothing is ever sent to it by the lister.

So, in OciTagLister.ResolveCredential β€” selecting the credential:

The key selected is the one held for… …because the key is sent to
the container registry's own host, when a plugin registry is configured there the operator already gave this installation a key for that exact host β€” a portal serving its images from the /v2 mirror it also serves its catalog from the container registry
the host named by SelfUpdate:RegistryValidationUrl, when a plugin registry is configured there the operator declared that portal as the one that validates this installation's key at that registry, AND gave this installation a key for it the container registry β€” still, never the validator
nothing else, ever an absent declaration is a refusal, not permission nothing is sent

Both rows need TWO explicit statements. A declaration alone grants nothing (there is still no key for that host); a key alone grants nothing (nothing says that registry trusts that portal).

The SHAPE of the credential follows the row

🚨 A declared validator is handed the DURABLE mwi_ key, never an exchanged token. The Store's resolver (RegistryTokenResolver.ResolveToken) presents a configured Token as configured, but a STORED key β€” the one an auto-registered installation holds after PluginCatalog:BootstrapKey, the zero-touch shape InstanceProvisioningPlan documents β€” it first EXCHANGES for a short-lived mwa_ token at /api/instances/token. On the fleet's shape the declared validator is that exchange endpoint, and it refuses a token by design (a token may never mint its successor; InstanceTokenEndpoints). So a lister resolving through ResolveToken would present mwa_… to cr.meshweaver.cloud, be refused on every check, and fix only the installations with a raw Token configured β€” build and pearl β€” while the auto-registered ones stayed frozen (review of #4094). The second row therefore resolves through ResolveDurableKey: the configured token, else the stored key decrypted, no exchange. The first row is unchanged β€” a portal's own /v2 mirror runs the same authenticator the plugin registry does and accepts both shapes. OciTagListerTest pins it with the auto-registered shape: the presented secret is the stored mwi_ key and the exchange route is never called; resolving through ResolveToken reports one exchange and a refused listing.

🚨 Measured: with the target check removed, the instance key reaches an arbitrary host β€” twice

Not an argument, a measurement. Delete the bare-host requirement, point SelfUpdate:Registry at instance:mwi_…@evil.example.test, declare any validator this installation holds a key for, and the test fixture's attacker host receives two credentials: the Basic credential at the token realm it names, then the Bearer on the listing. OciRegistryClient builds https://{registry}/ for a value with no scheme, so that string is a valid URI whose host is evil.example.test.

That is why the bare-host requirement is not negotiable, and why it is host-EQUALITY rather than "parses to a host". Everything below explains the shape; this is the reason it exists.

This IS a trust expansion β€” say so

Before this change, the instance key could reach only a host that had a plugin registry configured on it. After it, the key can reach ANY host, provided some configured plugin registry is declared to be that host's validator. That is the feature working as designed β€” the fleet's registry is exactly such a host β€” and it is fail-closed. But it widens who can receive a credential, and three things bound it:

  1. An explicit declaration. SelfUpdate:RegistryValidationUrl must name the validator. Absent, nothing changes; absence is never permission, and no resemblance of names substitutes for it.
  2. A bare-host target. SelfUpdate:Registry must already be host[:port] (below).
  3. A plugin registry actually configured at the declared validator. A declaration for a host this installation holds no key for grants nothing β€” there is no key to select.

Remove any one of the three and the expansion becomes unbounded. They are not defence in depth; each one is load-bearing.

The destination is validated first, and here is the attack that requires it

SelfUpdate:Registry must already be a bare host[:port]. OciRegistryClient builds https://{registry}/ for a value with no scheme, so instance:mwi_…@evil.example is a valid URI whose host is evil.example β€” it receives the Basic credential, and the raw value is interpolated into a dozen of that client's error messages besides.

🚨 The fix introduced the vulnerability it exists to prevent. Host equality could never match instance:mwi_…@evil.example, so the old code always refused it. The declared-validator rule matches on the declared host and never looks at the target β€” so the new trust path made a previously unreachable value reachable. Measured, not argued: with the target check deleted, the test fixture's attacker host received two credentials (Basic at the token realm, then the Bearer).

That is why the requirement is a BARE HOST and not merely "parses to a host". Do not simplify it back to HostOf(registry): normalizing silently would accept a URL form here while PortalImage/MigrationImage β€” which interpolate the same value β€” stayed malformed, and the constraint would have lost the reason it exists. A value that does not parse to an http(s) host is refused without being echoed, because the userinfo is where a key would be.

🚨 "Bare" is a textual test on the configured value, not equality with the parsed host. HostOf drops a scheme-default port, so a check written as HostOf(value) == value refused cr.example.test:443 β€” a legal host:port this page and the chart promise β€” with a message claiming it "carries a scheme or a path" (review of #4094). The check is now: no scheme, no path, no query, no fragment (userinfo was refused a line earlier); the client and the credential match then use the normalized host.

SelfUpdateOptions.HostOf is the platform's ONE registry-host rule, not this feature's. It used to have a twin β€” RegistryUpdateReconciler.SameRegistry decided whether a ModulePublished broadcast names a configured registry with its own reader, with different port semantics (http://x:443 and https://x were the same registry there and different hosts here). SameRegistry now reads through HostOf, so the two subsystems agree: a scheme-default port is not part of the host, any other port is, and a value that names no http(s) host β€” a bare non-URL, a mailto:, a URL carrying userinfo β€” names no registry anywhere rather than matching a second copy of the same unreadable string.

Why host equality was wrong

The fleet's registry deliberately does not hold credentials. cr.meshweaver.cloud decides a pull by forwarding the caller's password to a portal β€” docker_auth's ext_auth hook POSTs it to registry.validationUrl, by default https://memex.meshweaver.cloud/api/instances/token, and grants pull on a 200 (A Container Registry in Memex). That is what makes ONE mwi_ key serve both the plugin catalog and the image pull, exactly as RegistrySpec.ValidationUrl says it does:

The registry's own pull is decided by the portal at ValidationUrl β€” the same instance-key gate the plugin registry uses β€” so a consumer holds ONE credential for both.

So the image registry and the plugin registry are two different hosts by design. A rule that presents the key only to a host that is itself a plugin registry can therefore never authenticate on the fleet's own shape. Measured 2026-09-12 on the first boot of Deployments/build (#4093): the kubelet pulled the image in six seconds with that very key, and the self-updater in the same pod refused to use it β€”

[SelfUpdate] check (Startup): check FAILED: InvalidOperationException: SelfUpdate:Registry is
'cr.meshweaver.cloud' … but no plugin registry … is configured on that host

Every instance provisioned on the fleet registry booted fine and then never self-updated. Reconcile and Roll from the control instance still worked, which is why the state is easy to miss: the instance is healthy, it is merely frozen.

Why the obvious fix is the wrong fix

The guard is a credential-disclosure control. SelfUpdate:Registry names a host; without the check, whatever that host happens to be gets handed this installation's instance key. So none of these is acceptable:

What makes the pairing safe is that the registry declares which portal it trusts with the key. That declaration is already data β€” RegistrySpec.ValidationUrl β€” so the fix restates it on the consuming side rather than inventing a new concept.

Where the running portal learns the pairing

🚨 It does not learn it by itself, and that is deliberate. RegistrySpec lives on the record of the instance that HOSTS the registry (Deployments/memex-cloud), on the control instance. A CONSUMER's record (build, pearl) has no registry block at all, HelmValues renders registry.validationUrl only for the hosting record, and reaching across instances to read it would put a network dependency inside the credential path β€” a lookup that fails open, or fails the update.

So the pairing is an explicit declaration the operator sets, in the consumer's own configuration:

where what
SelfUpdate:RegistryValidationUrl the config key the portal binds (SelfUpdateOptions.RegistryValidationUrl)
selfUpdate.registryValidationUrl in the chart renders SelfUpdate__RegistryValidationUrl into the portal ConfigMap
a record's extraPortalConfig / an overlay's config.memex_portal renders the same key β€” this is how an existing instance is fixed without a chart change

The value is the registry record's validationUrl, copied verbatim (https://memex.meshweaver.cloud/api/instances/token); a bare host means the same thing. Only the host is ever read, and it is read whole: a non-default port is part of it, and a value carrying userinfo (https://memex.meshweaver.cloud@evil.example) declares NOTHING rather than a pairing with the host a human would not have read. An empty value β€” the default β€” declares nothing and refuses.

🚨 "Declared" and "readable" are two questions, and the refusal says which one failed. A value that is SET but names no http(s) host β€” userinfo, a typo'd scheme, a mailto: β€” is refused as malformed (SelfUpdate:RegistryValidationUrl is set but does not name an http(s) host, value not echoed), never folded into "nothing declared": collapsing the two told the operator to declare the key they had already set, a fail-closed fallback forging a correct-looking bug (review of #4094). The boot line has the same three states: validated at {host}, SET but unreadable, NO validator declared. SelfUpdateOptions.RegistryValidatorDeclared is the predicate; RegistryValidatorHost is the reading.

The alternative: derive it on the control instance β€” recorded, not done

The pairing could be derived at render time instead of hand-copied: HelmValues already derives selfUpdate.registry from the image host, and the hosting record whose registry.host equals it carries the validationUrl. One declaration on the registry's own record, no per-consumer copies, still no network in the update path β€” the consumer-side key stays the wire; only who writes it changes. It would also give PluginBundleClient.DownloadArtifact, which today presents the same key to whatever host the catalog advertises with no declaration at all, the same rule as the self-updater β€” one key/host pair currently lives under two trust rules. That is a real alternative to this page's design, and it is deliberately NOT part of #4094: it moves where a trust rule lives. #4123 carries it, ordered after the config-repo declaration that closes #4093. The design β€” the render-time derivation in HelmValues, the bundle-client rule keyed on the registry's OWN declaration rather than on SelfUpdate:Registry (the control instance pulls from ACR and adopts bundles sealed on cr.meshweaver.cloud), and the alternative of moving detection to the control plane altogether β€” is written down in Self-Update on the Control Lane β†’ "#4093 and #4123".

Since MeshWeaver#4098 the key this page selects is presented for detection only: a fleet instance lists the registry with it and hands the selected release to the control instance; it no longer patches anything itself (Self-Update on the Control Lane).

What the refusal says

The message names the hosts and the key that would declare the pairing. It never names, echoes, lengths or logs the credential itself. The one new log line, at Information on the paired path only, names the plugin registry, the container registry and the declared validator β€” so "who did this installation hand its key to" is answerable from Loki, and a pairing nobody declared can never produce a line.

Two diagnoses exist so that a wrong one is never given:

The negative control

OciTagListerTest pins both directions, and the negatives are what prove the guard still guards:

🚨 A negative control must be proven to have RUN, not merely to be green. Both traps were hit while writing these, and both produced a confident pass:

The vacuity audit β€” ask "could this assertion FAIL?", not "does it assert the right property"

A vacuous negative is a class, and its mechanism is now known: a fake that refuses early can never observe what it was built to catch. So every negative here was audited against that one question, by deleting the guard it protects, stripping the test to the assertion under audit, and measuring what it reported. Two of the five shared the shape, and the hostile-host fixture fixed both:

Every negative asserts the DISCLOSURE first (CredentialsSeen, recorded before the fake's host check) and the message second, through Record.ExceptionAsync rather than Assert.ThrowsAsync β€” so a falsification reports what leaked, not "no exception was thrown". Re-run in the review round of #4094, each patch built under -warnaserror before it ran (a patch that did not compile is VOID, and the runner refuses it rather than replaying the stale assembly β€” the first attempt was exactly that):

negative guard deleted β‡’ the assertion reports vacuous?
no plugin registry on that host 2 credentials at other.example.test was β€” an unknown host was 404'd before any credential could be observed; fixed by the hostile-host fake
the same registry, nothing declared 3 credentials no
a declared validator holding no key 3 credentials no
a target carrying userinfo 2 credentials at evil.example.test was β€” the case that started the audit
a URL-form target (3 shapes) the no plugin registry message for the raw value; under the equality form of the check, host:443 refused with an untrue message no
a declaration set but unreadable (3 shapes) the old "no validator is declared" diagnosis no
a plugin registry whose URL carries credentials (2 rows) the old "no plugin registry is configured" diagnosis; 3 credentials when the host guard goes no
the boot line the line with instance:mwi_…@evil.example.test in it; the two-state line saying "NO validator declared" no
a refused key faults, never an empty list No exception was thrown under a .Catch(empty) no
a third host reached (the positives' ReachedHosts) Expected all items to match with third.example.test in the list was β€” ServedHosts was appended only for KNOWN hosts, so an un-credentialed request to a third host was invisible by construction; every host is now recorded before the fake decides what it serves
the auto-registered shape presents the durable key ExchangeRequests 1, expected 0 under ResolveToken no
the key held for the DECLARED validator, two registries mwi_issued-by-a… presented instead of B's under registries.First() no β€” and with one registry configured this assertion could not exist

The pure-function negatives (the validator-host reader, declared-vs-readable) have no fake in the path and cannot take this shape.

The same audit is owed across the wider suite; it is not part of this change.

See also

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.