Repository navigation
feat(secrets): write-only secret entry in the portal — InstanceSecrets, the secret control, and client-b's announcement key without a vault act - #5791
Conversation
…s, the secret control, and the announcement key without a vault act
A global administrator ENTERS a secret in the portal instead of an operator minting it:
- SecretFingerprint (fleet rule: sha256:+12 hex, withheld below 128 estimated bits) and
SecretStatus (the operator's kv_status field names) in Mesh.Contract.
- InstanceSecrets: slot-registered configuration keys stored enc: in Admin/Secret-{key},
rights checked as the caller then written as system, read LIVE through a mesh-scoped
catalog (no restart). Set / Generate-once / Disable / Enable / Delete / Recover, rotation
with the previous value still verifying, last-use recording.
- WriteOnlySecretSection / SecretInventorySection: the platform's one write-only control.
- WebhookInbox: per-sender keys from the portal as well as configuration, a rotation's
previous key, and a verify-only test header that stores nothing.
- SelfUpdateHandover signs with the portal-set key when there is one, records each
announcement's result, and offers Test() (signed, verify-only).
- Settings > Control lane: enter the control instance's announcement key, see its
fingerprint, Test connection.
- Docs: Architecture/InstanceSecrets (new) and SelfUpdateAnnouncementKey (portal flow,
client-facing steps).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…og then shows only the fingerprint and the note Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved critical and moderate security, authorization, catalog, and rotation issues remain.
Review effort: Lite
Findings: 8
Open (21)
Block unauthorized direct InstanceSecret CRUD · New Use reversible encoding for secret storage paths · New Canonicalize secret keys before constructing node IDs · New Gate signing on catalog readiness · New Reject reserved enc prefix before protecting values · New Remove public decrypted-secret enumeration · New Fail closed when the catalog feed is unavailable · New Preserve system identity across catalog subscriptions · New Avoid async lambda in hub-reachable HTTP code · New Record all announcement failures as unsuccessful key use · New Enforce the 14-day maximum rotation overlap · New Wait for catalog loading before checking secret presence · New Clear password buffers on all pipeline outcomes · New Localize connection-test exception details · New Localize LastUseResult instead of rendering raw text · New Localize InstanceSecrets operation failures · New Localize secret source identifiers · New Use RunAsSystem instead of Observable.Using impersonation · New Avoid cross-thread AsyncLocal impersonation scopes · New Replace Observable.Using impersonation in the test · New
And 1 more that still need to be addressed.
What changed in this PR
Adds encrypted, write-only instance secrets for portal-managed self-update announcement keys, with Control lane UI, webhook verification, localization, documentation, and tests.
Changes:
- Adds secret storage, fingerprinting, rotation, lifecycle operations, and live cataloging.
- Integrates portal keys with announcement signing and webhook verification.
- Adds localized UI, documentation, and integration coverage.
| File | Description |
|---|---|
test/MeshWeaver.Graph.Test/InstanceSecretsTest.cs |
Secret storage and behavior tests |
test/Memex.Portal.Shared.Test/PlatformSettingsTabSeedTest.cs |
Settings-tab seed coverage |
test/Memex.Portal.Shared.Test/ControlLaneKeyRoundTripTest.cs |
End-to-end key round-trip tests |
src/MeshWeaver.Messaging.Hub/Localization/strings.en.json |
English localization |
src/MeshWeaver.Messaging.Hub/Localization/strings.de.json |
German localization |
src/MeshWeaver.Mesh.Contract/Security/SecretStatus.cs |
Secret status contract |
src/MeshWeaver.Mesh.Contract/Security/SecretFingerprint.cs |
Fingerprint contract and generation |
src/MeshWeaver.Graph/Configuration/WriteOnlySecretSection.cs |
Write-only secret UI |
src/MeshWeaver.Graph/Configuration/WebhookEventNodeType.cs |
Sender-key and verify-only handling |
src/MeshWeaver.Graph/Configuration/InstanceSecrets.cs |
Encrypted secret storage and live catalog |
src/MeshWeaver.Graph/Configuration/GraphConfigurationExtensions.cs |
Secret type registration |
src/MeshWeaver.Documentation/Data/Architecture/SelfUpdateAnnouncementKey.md |
Announcement-key workflow documentation |
src/MeshWeaver.Documentation/Data/Architecture/InstanceSecrets.md |
Instance-secret documentation |
src/MeshWeaver.Documentation/Data/Architecture.md |
Architecture topic map |
memex/Memex.Portal.Shared/Settings/PlatformSettingsTabAreas.cs |
Settings-tab registration |
memex/Memex.Portal.Shared/Settings/ControlLaneSettingsTab.cs |
Control lane settings UI |
memex/Memex.Portal.Shared/SelfUpdate/SelfUpdateHandover.cs |
Portal-key signing and testing |
memex/Memex.Portal.Shared/MemexConfiguration.cs |
Secret slot registration |
memex/Memex.Portal.Shared/Api/WebhookInboxEndpoints.cs |
Verify-only response contract |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| Name = "Instance Secret", | ||
| Icon = "/static/NodeTypeIcons/key.svg", | ||
| // Never offered for creation, never indexed as context: the content is ciphertext and metadata. | ||
| ExcludeFromContext = new HashSet<string> { "search", "create", "content" }, |
There was a problem hiding this comment.
Fixed in 99fa68b. The new InstanceSecretWriteGuard is an owner-enforced INodeValidator that refuses any create, update or delete of an InstanceSecret node unless the writer is system or a hub, so the verbs are the only way in. The catalog also uses only enc:-tagged values now, so plain content that got past the guard would still never be used as a key. Both are pinned in A_direct_write_is_refused_and_a_plain_value_is_never_used.
| }; | ||
|
|
||
| /// <summary>The node id for <paramref name="configKey"/>: <c>Secret-</c> plus the key with <c>:</c> as <c>--</c>. Pure.</summary> | ||
| public static string IdOf(string configKey) => IdPrefix + configKey.Trim().Replace(":", "--"); |
There was a problem hiding this comment.
Fixed in 99fa68b. Admits refuses any key containing -- (the id separator), so the encoding is injective. Pinned in A_key_that_would_collide_in_the_node_id_is_not_admitted.
| }; | ||
|
|
||
| /// <summary>The node id for <paramref name="configKey"/>: <c>Secret-</c> plus the key with <c>:</c> as <c>--</c>. Pure.</summary> | ||
| public static string IdOf(string configKey) => IdPrefix + configKey.Trim().Replace(":", "--"); |
There was a problem hiding this comment.
Fixed in 99fa68b. IdOf lower-cases the key, so two spellings address one node. Pinned in Two_spellings_of_one_key_address_one_node.
There was a problem hiding this comment.
(re-posted 2026-10-11; originally by Copilot on 2026-09-27)
Admits intentionally compares keys case-insensitively, and the catalog dictionary does too, but IdOf preserves casing. Thus Hosting:PlatformWebhookSecret:client-b and hosting:platformwebhooksecret:client-b are both admitted yet create different Admin/Secret-* nodes; later catalog folding makes them collide nondeterministically and can select the wrong ciphertext/status. Canonicalize the key before constructing the node id.
| var catalog = hub.ServiceProvider.GetService<InstanceSecretCatalog>(); | ||
| if (catalog?.Entry(configKey) is { IsDeleted: false } entry) | ||
| return entry.IsDisabled ? null : entry.Current; | ||
| var value = hub.ServiceProvider.GetService<IConfiguration>()?[configKey]; | ||
| return string.IsNullOrWhiteSpace(value) ? null : value; |
There was a problem hiding this comment.
Fixed in 99fa68b. SelfUpdateHandover.Announce and Test now wait on InstanceSecretCatalog.WhenLoaded before they choose a route or a key, so a portal key or a portal disable is never skipped during the first listing.
| string? stored; | ||
| try | ||
| { | ||
| stored = protector.Protect(value); |
There was a problem hiding this comment.
Fixed in 99fa68b. Set refuses a value that starts with enc: before it calls the protector (secret.error.reservedPrefix). Pinned in A_value_carrying_the_encryption_tag_is_refused.
| if (status.SetAt is not null || status.SetBy is not null) | ||
| lines.Add(localize("secret.status.setBy", [Stamp(status.SetAt), status.SetBy ?? "?", status.Source ?? "?"])); |
There was a problem hiding this comment.
Fixed in 99fa68b. SourceLabel maps paste and generate to secret.source.*. An unknown value is shown as it is, because the vocabulary is open (policy open-vocabulary-string-constants: unknown stays unknown and is never folded into a known value).
| var stored = await Observable.Using( | ||
| () => Access.ImpersonateAsSystem(), | ||
| _ => Mesh.ServiceProvider.GetRequiredService<IMeshService>() | ||
| .Query<MeshNode>(MeshQueryRequest.FromQuery($"path:{SelfUpdateHandover.InboxTarget}/{WebhookInbox.InboxContainer} scope:children")).Take(1)) | ||
| .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); |
There was a problem hiding this comment.
Fixed in 99fa68b. The test now uses Access.RunAsSystem(() => …).
| Observable.Using( | ||
| () => Access.ImpersonateAsSystem(), | ||
| _ => MeshService.Query<MeshNode>(MeshQueryRequest.FromQuery($"path:{InstanceSecrets.PathOf(key)}")).Take(1) | ||
| .Select(c => c.Items.FirstOrDefault())) | ||
| .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); |
There was a problem hiding this comment.
Fixed in 99fa68b. Both helpers now use Access.RunAsSystem(() => …).
| () => Access.ImpersonateAsSystem(), | ||
| _ => MeshService.CreateOrUpdateNode(new MeshNode(Target) | ||
| { | ||
| Name = Target, | ||
| NodeType = "Markdown", |
There was a problem hiding this comment.
Fixed in 99fa68b. This one now uses Access.RunAsSystem(() => …).
| () => Access.ImpersonateAsSystem(), | ||
| _ => MeshService.Query<MeshNode>(MeshQueryRequest.FromQuery( | ||
| $"path:{Target}/{WebhookInbox.InboxContainer} scope:children")).Take(1)) | ||
| .Select(c => c.Items.Count(n => n.NodeType == WebhookInbox.NodeType)) | ||
| .FirstAsync().Timeout(Budget).Await(TestContext.Current.CancellationToken); |
There was a problem hiding this comment.
Fixed in 99fa68b. This one now uses Access.RunAsSystem(() => …).
…eds no vault act or mount Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-announcement-key # Conflicts: # memex/Memex.Portal.Shared/MemexConfiguration.cs # src/MeshWeaver.Documentation/Data/Architecture.md
…fix refused, fail closed on a dead feed, catalog-ready gates, keyed texts - InstanceSecretWriteGuard refuses non-system create/update/delete of InstanceSecret nodes; the catalog uses only enc:-tagged values. - Node id is lower-cased; keys containing '--' are not admitted (no collisions). - A value starting with 'enc:' is refused (the protector would store it verbatim). - Rotation overlap clamped to 14 days; the enumeration surface is removed. - Catalog subscribes via RunAsSystem; a faulted feed fails CLOSED (no per-sender keys). - Writes and the self-updater's Announce/Test wait for the catalog's first listing. - Every announcement outcome is recorded, transport failures included. - Refusals, last-use results, test verdicts and sources render in the viewer's language. - Password buffer cleared on every termination of the read. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
ℹ️ Merge-queue steward: no action — removed from the queue with reason |
…-announcement-key # Conflicts: # src/MeshWeaver.Documentation/Data/Architecture/SelfUpdateAnnouncementKey.md
|
⛔ Merge-queue steward: left dequeued — a job other than a test shard failed — a build or gate failure is never a flake.
Not a catalogued flake. Fix the failure, or — with run URLs, an issue and an assertion-message pattern — add it to |
…ward re-queues it, capped Five core PRs (#5789 #5791 #5792 #5793 #5795) sat queue-rejected on 2026-09-27 for one reason: 'MeshWeaver.Plugins did not answer within 42 min (no verdict yet)' — the dependent's legs never got a runner, while every candidate that did run was green. The steward classed that as a gate/build failure ('never a flake') and left them out. - await-dependent-verdict.py: silence exits EXIT_NO_VERDICT=3 (still red, never a pass); every verdict-shaped red stays exit 1. - dotnet-test.yml: the wait step hands exit 3 to its own failing step, 'No verdict in time: the dependent's suites did not report (infrastructure)'. - merge-queue-steward.py: a Dependent-suites job that failed ONLY on that step is starved, not failed — re-queue kind=infra, capped 2 per head sha with the marker comment; beside a build failure or an uncatalogued assertion it still rejects; a verdict red still rejects. Nine new self-test rows (66 ok). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…pp tab too The merge of main unioned the localization keys but left main's new admin-only "Control lane" seed on the global settings page. The merged seed test lists ControlLane among the admin tab ids (main) and requires every admin tab to be an Admin-app tab (this branch), so PlatformSettingsTabSeedTest failed 3 of 6 at bac07bd. Move it like the other Administration seeds: NodeSettings context, gated AdminOnly + NodeTypes [AdminApp], and listed in AdminAppTabIds so the old /GlobalSettings/ControlLane link redirects to /Admin/Settings/ControlLane. AdminApp.md's inventory lists it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>



What
A secret is entered through a write-only page, never minted by an operator in a vault. This is the core half, and the first user is client-b's self-update announcement key: client-b is customer-administered, and its administrator must be able to enter the key with no
azand nokubectl. Refs #5757, #5775.Platform pieces, which other secrets reuse
SecretFingerprint(MeshWeaver.Mesh.Security) is the fleet-wide rule, byte for byte the same as the operator'smw-fpvault tag. It issha256:plus 12 hex digits, orwithheld:low-entropywhenfloor(len × log2(alphabet)) < 128. It also offersGenerate(), a 256-bit hex key.SecretStatus: the only shape a UI reads back. Its JSON names are the operator'skv_statusfields (name,present,enabled,created,updated,expires,deleted,recoverableUntil,fingerprint,setBy,setAt,source). The Key Vault agent confirmeddeletedis a bool andenabledis null when absent.InstanceSecrets(MeshWeaver.Graph.Configuration): the instance's own encrypted store.Admin/Secret-{key}asenc:ciphertext fromIProviderKeyProtector. A value the protector cannot encrypt is refused, never stored in the clear.AddInstanceSecretSlot). A:*slot admits exactly one more segment and never the section itself, so the fleet inbox secret cannot be replaced.IsGlobalAdmin), and the write then runs as system.InstanceSecretCatalog, one query subscription per mesh, so no restart is needed.Set,Generate(returns the value once),Disable(a tombstone that also suppresses a mounted copy),Enable,DeleteandRecover(with a recovery window).RecordUserecords the last use.WriteOnlySecretSection/SecretInventorySection: the platform's one write-only control.The announcement key
WebhookInboxnow verifies per-sender keys from the portal as well as from configuration, and a rotation's previous key (MatchSender).X-MeshWeaver-Verify-Only: trueasks for verify only, store nothing. That is what "Test connection" uses.sender, the name of the key that verified (never its value).SelfUpdateHandoversigns with the portal-entered key when there is one, and otherwise with the mountedHosting:ControlInbox:Secret.Test(), which sends a signed, verify-only test and returns Match (as whom) or Mismatch.Docs
Doc/Architecture/InstanceSecretsis new.Doc/Architecture/SelfUpdateAnnouncementKeygains the portal flow and a client-facing step-by-step section.Verification
-warnaserror, one project per build:MeshWeaver.Graph,Memex.Portal.Shared,MeshWeaver.Graph.Test,Memex.Portal.Shared.Test,MeshWeaver.Documentation.Test,MeshWeaver.Messaging.Hub.Test. All report 0 warnings and 0 errors.InstanceSecretsTestpasses 22/22. It covers:enc:, resolved live, and never appears in any log line;ControlLaneKeyRoundTripTestpasses 8/8. It runs the whole loop in one mesh: the control issues a key, client-b saves it, the fingerprints are equal, andTest()answers verified asclient-b, with the result recorded on both ends. A mistyped key reads as a 401 mismatch, nothing is stored, and the key never reaches a log.WebhookInboxTestpasses 14/14 (unchanged). TheSettings/SelfUpdatesuites pass: 14 and 134.MeshWeaver.Documentation.Testpasses 649/649, includingRootedRxConnectionRatchetGuard, which caught aRefCountthat is now removed.Cross-repo
Pairs-with: none — nothing public is removed; types and members are only added (
DeliveryResult.VerifyOnlyis an init property).Implementers: none — no interface member is added.
Mirror-sync:
npm run sync:i18n -- --ref <merged core sha>is carried by the MeshWeaver.Plugins companion PR (the record-page Announcement key panel), which follows once this is sealed. It adds keys only:settings.controlLane,ui.controlLane*,hosting.annKey.*,secret.*.After deploy, nothing needs recycling: the settings tab and the catalog are compiled platform code, and they arrive with the image.
🤖 Generated with Claude Code