Runbook — Credential storage algorithm switch (per-tenant)
Switch a tenant’s at-rest credential-encryption algorithm (for example from the default AES-256-GCM world posture to a regional posture such as SM/ShangMi or GOST) and re-seal existing credential ciphertext under the new algorithm.
Audience: Platform / Integrations / ReleaseOrchestrator operators in regulated regions (eIDAS, FIPS, GOST R, SM/ShangMi). Sprint: SPRINT_20260529_028 (RP-028-014 — sister of RP-028-009…013). Applies to: the three credential-ciphertext stores (platform.connector_credentials, integrations.integrations, release_orchestrator.deployment_bundles). Related ADRs: ADR-008 (regional algorithm routing), ADR-007 (KEK lifecycle). Related runbooks: kek-rotation.md, kek-source-vault.md, kek-source-hsm.md.
When to switch
A per-tenant credential storage algorithm switch is the right move when:
- Compliance posture changes. Tenant moves from
world(default AES-256-GCM) to a regional posture (eIDAS / FIPS / GOST / SM). - Regional regulation update. Auditor requires the regional algorithm on existing data, not just on new credentials.
- Plugin upgrade. A regional plugin was previously unavailable; the operator has now provisioned and loaded it, and wants existing rows rotated under the regulator-recognised algorithm.
The algorithm choice is per-tenant, not per-installation: different tenants on the same install can carry different algorithms simultaneously, because the dispatch layer (ICredentialAlgorithmSelector reading the tenant’s compliance profile + the per-row algorithm column + ICredentialAeadPrimitive) selects the algorithm per row by that row’s tenant. (The per-row algorithm column name differs by store: secret_alg in platform.connector_credentials, credential_alg in integrations.integrations, and alg in release_orchestrator.deployment_bundles.)
Important — the re-seal walker is NOT tenant-scoped. stella crypto profile set <p> --reseal --tenant <id> does two distinct things: (a) the --tenant flag scopes the profile change to that tenant; (b) --reseal then triggers a KEK rotation across the entire kek_id store (the walker batches WHERE kek_version = oldVersion with no tenant predicate — see CredentialReSealWalker / ReSealRequest). Every row on that store is re-sealed, and each row’s new algorithm is whatever the selector returns for that row’s tenant. So a row owned by a tenant still on world is re-sealed back under AES-256-GCM in the same pass, while the just-switched tenant’s rows move to the regional algorithm. The net effect is the desired per-tenant outcome, but the operator should understand the walker touches all tenants’ rows for the kek_id, not only the named tenant’s.
Prerequisites
Required scopes. The operator’s token (or the
stellaCLI’s resolved credentials) must carry the right Platform crypto scopes — each is also satisfied byops.admin:
crypto:profile:admin— set the tenant compliance profile (PUT /api/v1/admin/crypto-providers/compliance-profile, policyplatform.crypto.profile.admin). NB: the read-only validate verb (GET /api/v1/admin/crypto/profile/validate) lives under a different path prefix and a different scope (crypto:read, see below).crypto:kek:rotate— begin/abort the re-seal walker (POST .../kek/{id}/rotate, policyplatform.crypto.kek.rotate).crypto:read—stella crypto profile validate/show(policyplatform.crypto.read).crypto:kek:read—stella crypto kek status/history(also satisfied bycrypto:kek:rotate; policyplatform.crypto.kek.read).Scope constants are canonical in
src/Authority/.../StellaOps.Auth.Abstractions/StellaOpsScopes.cs(crypto:profile:admin,crypto:kek:rotate,crypto:read,crypto:kek:read); the Platform policy registrations live insrc/Platform/StellaOps.Platform.WebService/Program.cs.
The correct regional plugin must be loaded and registered. When
PrimitiveRouter=pluginis set, Platform eagerly initializes the bundledSmPlugin/FipsPlugin/GostPluginon first resolve (seeRegionalCryptoPluginServiceCollectionExtensions); init failures bubble as a startup error rather than silently falling back to AES. Inspect the bootstrap log — the regional plugins log under theRegionalCryptoPlugin.Sm/RegionalCryptoPlugin.Fips/RegionalCryptoPlugin.Gostcategories, and the generic loader logsLoading crypto plugin manifest from:/Failed to load crypto plugins:docker logs platform | grep -iE "RegionalCryptoPlugin|crypto plugin"A clean boot with the plugin router enabled means the adapters (
Sm4GcmAdapter/AesGcmFipsAdapter/GostGcmAdapter) are wired into thePluginRoutedCredentialAeadPrimitive. Note: there is no separateStellaOps.Cryptography.Plugin.Smexternal plugin DLL to discover here — the regional plugins are bundled and DI-registered in-process.Plugin routing must be enabled (SPRINT_20260528_035 / S035-003). Default DI on Platform binds the AES-only path so installs without regional crypto requirements keep Sprint 027 byte-identity untouched. Two opt-in flags activate the regional dispatch — wire BOTH for the full per-tenant-profile path:
{ "Crypto": { "CredentialStore": { "PrimitiveRouter": "plugin", "Selector": "tenant-compliance" } } }What each flag changes (Platform
Program.cs):Flag Effect when unset Effect when set Crypto:CredentialStore:PrimitiveRouter=pluginICredentialAeadPrimitive→IAeadAlgorithmCredentialPrimitive(AES-only viaIAeadAlgorithm)ICredentialAeadPrimitive→PluginRoutedCredentialAeadPrimitivewith SmPlugin / FipsPlugin / GostPlugin adapters wiredCrypto:CredentialStore:Selector=tenant-complianceICredentialAlgorithmSelector→DefaultCredentialAlgorithmSelector(returnsAES-256-GCMfor every tenant)ICredentialAlgorithmSelector→TenantComplianceProfileCredentialAlgorithmSelector(consultsplatform.tenant_compliance_profile→CredentialAlgorithmProfileMap)Equivalent env-var form for
docker-compose.env:Crypto__CredentialStore__PrimitiveRouter=plugin Crypto__CredentialStore__Selector=tenant-complianceAccepted values (case-insensitive):
PrimitiveRouter:default/aead/iaeadalgorithm(AES-only),plugin/plugins/router(regional). Any other value = fail-fast at boot (no silent downgrade — a typo on a regulated install must not silently revert to AES).Selector:defaultor empty (AES-only),tenant-compliance/tenantcompliance(consult tenant compliance profile). Any other value = fail-fast at boot.
Restart Platform after changing either flag — the primitive + selector are bound at DI composition.
Integrations + ReleaseOrchestrator scope. S035-003 wires these flags on Platform only. Integrations + RO build their own
CredentialAeadinstances directly withIAeadAlgorithm(distinctaadPrefix+kdfLabel, mutually undecryptable with Platform’s ciphertexts) and do NOT currently bindICredentialAeadPrimitiveorICredentialAlgorithmSelectorin DI — so the conditional opt-in has nothing to swap there. Their local service-secret stores therefore continue on the Sprint 027 AES-256-GCM path; mirroring the regional dispatch into those services is a separate ladder (each service’s credential surface is much smaller than Platform’s connector store, and the regulated-customer requirement starts at the connector store per ADR-008). The live forcing-function smoke (S035-004 dossier) targets Platform’splatform.connector_credentialstable accordingly.stella crypto profile validatemust returnokunder the target profile. The verb validates the profile of the active request tenant (resolved server-side from the auth/tenant context byGET /api/v1/admin/crypto/profile/validate) — it does not accept a--tenantargument. To validate a different tenant, invoke the CLI with that tenant’s credentials/context. Use--format jsonfor the machine shape:stella crypto profile validate --format jsonExpected output (the server wire shape, lowerCamelCase):
{ "outcome": "ok", "activeProfile": "sm", "activeAlgorithm": "SM4-GCM", "primitiveKind": "plugin_routed", "pluginReachable": true, "fipsModeDisagreement": false, "notes": [] }(
primitiveKindisdefaultwhen the AES-onlyIAeadAlgorithmCredentialPrimitiveis bound andplugin_routedwhen the plugin router is active.) The endpoint’soutcomeis one ofok,profile_inactive,no_plugin_matches,fips_disagreement,roundtrip_failed. If the outcome isno_plugin_matches, the configured primitive does not support the profile’s algorithm (regional plugin not registered or no adapter wired). Iffips_disagreement, the active profile isfipsbut the host is not FIPS-validated. (As of RP-028-013 the Platform FIPS-mode probeIsFipsProfileMisaligned()returnsfalseconservatively — a livefips_disagreementrequires the FipsPlugin’s cross-platform probe, which is kept out of the Platform assembly; the dossier test cases force the path directly.) Fix the underlying issue before switching — the walker will fail mid-way otherwise.KEK source healthy. The walker re-seals every row, which means it reads the OLD ciphertext and writes the NEW ciphertext, both under the active KEK. If the KEK source is unhealthy, the walker will fail; run
stella crypto kek statusfirst.
Switch workflow
1. Persist the tenant profile + trigger walker — single CLI invocation
stella crypto profile set sm --reseal --tenant <tenant-id>
What this does:
- Persists the new compliance profile (
smhere) via the tenant compliance profile store (ITenantCryptoComplianceClient.SetProfileAsync). - Maps the profile to the canonical algorithm (
MapProfileToAlgorithm):sm→SM4-GCMgost→GOST-GCMfips/eidas/world/kcmvp/ unset →AES-256-GCM
- Invokes
IPlatformCryptoKekClient.BeginRotationAsync(kekId, targetAlgorithm="SM4-GCM")—POST .../kek/{kekId}/rotatewith the?targetAlgorithm=query param appended. Platform validates the algorithm against the configured primitive’sSupportsAlgorithmregistry before launching the walker; an unsupported algorithm returns HTTP 400 (unsupported_target_algorithm/primitive_unavailable) without mutating state. - Streams walker progress to stdout, identical shape to
stella crypto kek rotate --confirm.
Exit codes (from CryptoCommandGroup + StreamRotationProgressAsync + the typed KEK client’s HTTP→exit-code map):
0— rotation reported complete: every store reached a terminal, non-failed/non-aborted state.1— the begin step hit a 409 concurrent rotation (an in-flight rotation for the samekek_idmust finish or be aborted first), OR the streaming loop ended in an aborted store-state, OR the operator detached with Ctrl+C (rotation continues server-side). In all three cases the profile change is already persisted.2— one or more stores ended instate=failedduring the walker run.12— the rotate call was rejected with 401/403 (missingcrypto:kek:rotate/ops.adminscope). The profile-set PUT itself requirescrypto:profile:admin.13— unknown profile name (rejected before any HTTP call).14— any other non-success HTTP response from the rotate endpoint, including the 400unsupported_target_algorithm/primitive_unavailable/rotation_rejectedcases. The profile change has been persisted; the walker has not run. Resolve the underlying issue and re-invoke with--reseal.
2. Override the KEK id if your install uses a non-default
The CLI defaults to --reseal-kek-id platform:connector-credentials:v1. Override if your install uses a different KEK id:
stella crypto profile set sm --reseal \
--tenant <tenant-id> \
--reseal-kek-id platform:connector-credentials:v2
Monitor progress
The CLI streams per-batch progress. The same data is available through:
Console-admin UI: two pages back this surface.
- The list page
/console-admin/crypto-control(“KEK Control Plane”) renders a Master Keys panel (one row perkek_id) and a Blob Populations panel (a stacked bar perkek_id, coloured by KEK version). This is where the population bar lives —crypto-control-list.component.ts. - The detail page
/console-admin/crypto-control/<kekId>surfaces the Active version panel + the Rotation history table for that one KEK (crypto-control-detail.component.ts); it does not render a Blob Populations bar — that is on the list page.
(The
target_algorithmthe operator requested is captured in the Platform audit detail block onRotateKekBegin, not as a field on the KEK status / history wire shape.)- The list page
Algorithm chip (UI, forward-looking). The Blob Populations panel rows (on the list page) each render an algorithm-breakdown chip per
(store, version)only when the server returns the optionalpopulations.algorithmBreakdownfield (shapealgorithmBreakdown[storeName][version][algorithm] = rowCount— the version key is a string in the JSON). As of this sprint the PlatformGetBlobPopulationsAsyncreturns only the(store → version → row-count)aggregate and does not populatealgorithmBreakdown, so the chip degrades to the single-bucket “AES-256-GCM (single bucket)” rendering. Treat the per-algorithm chip as a roadmap item until the server begins emitting the breakdown; until then, confirm the switch via the per-row algorithm column / a targeted DB query (secret_algforplatform.connector_credentials).CLI JSON:
stella crypto kek status --kek-id <id> --format jsonemits{ kekId, activeVersion, fingerprint, source, activatedAt, history, populations }, wherepopulationsis{ "stores": { "<store-name>": { "<version>": <rowCount> } } }. There is no per-tenant or per-algorithm breakdown in this payload (the populations aggregate is keyed by store + KEK version, not tenant or algorithm), so aselect(.tenantId=="...")filter has nothing to match. Inspect per-store version counts instead:stella crypto kek status --kek-id <id> --format json | \ jq '.populations.stores'
Mixed-state semantics (expected; not a bug)
During the walker run, some rows on this kek_id store will still be on the prior algorithm/version (the walker advances batch by batch). This is expected and safe:
- New Seal operations use the algorithm the selector returns for the writing tenant.
- Existing rows on the prior algorithm open via the prior algorithm’s primitive (the per-row algorithm column —
secret_algonplatform.connector_credentials— is authoritative on Open). - The plugin chain dispatches per row.
If your install has a high credential-access rate, some rows may re-seal naturally via Sprint 027’s “Seal-on-update” path before the walker reaches them — this is fine; the walker’s atomic swap ignores rows that are already on the target algorithm.
Verification post-switch
# 1. Profile is now persisted. `profile show` prints a fixed text table
# (no --format json option, no algorithm field):
stella crypto profile show --tenant <tenant-id>
# Expected (text):
# Tenant cryptography
# Tenant: <tenant-id>
# Profile: sm
# Updated by: <actor>
# 2. Validate returns ok under the new profile. (Validates the active
# request tenant — no --tenant arg; see Prerequisites step 3.)
stella crypto profile validate --format json
# Expected: { "outcome": "ok", "activeAlgorithm": "SM4-GCM", ... }
# 3. (Roadmap) The per-algorithm UI chip only reflects the switch once the
# Platform server emits populations.algorithmBreakdown. Until then, the
# list page /console-admin/crypto-control (Blob Populations panel) shows
# per-store version counts, not a per-algorithm breakdown. (The per-kek
# detail page /console-admin/crypto-control/<kekId> shows active version +
# rotation history only — no population bar.)
# 4. Scriptable check — per-store version populations (no per-tenant /
# per-algorithm field exists in this payload).
stella crypto kek status --kek-id <id> --format json | \
jq '.populations.stores'
# Confirm the switch authoritatively via the per-row algorithm column,
# e.g. SELECT secret_alg, count(*) FROM platform.connector_credentials
# WHERE tenant_id = '<tenant-id>' GROUP BY secret_alg;
Rollback
The walker is forward-only in mechanics (each row is re-sealed in place under the new algorithm) but bidirectional in workflow:
# Switch back to the prior posture.
stella crypto profile set world --reseal --tenant <tenant-id>
This sets the tenant’s profile back to world and runs the kek_id-wide walker again; the named tenant’s rows re-seal to AES-256-GCM (other tenants’ rows are re-sealed under their own profile’s algorithm, as in any pass). The walker treats the prior algorithm as the source and the new algorithm as the target; there is nothing special about the direction.
Caveat: the regional plugin must remain loaded for as long as ANY row on this install (across all tenants) carries the regional algorithm. If you unload the regional plugin while rows still reference it via their per-row algorithm column (secret_alg / credential_alg / alg per store), Open on those rows returns null rather than silently falling back to AES — the credential primitives are contractually non-throwing on Open (no plaintext-bearing exceptions), so the plugin-routed primitive and CredentialAead.Open return null for an unresolved algorithm and the caller surfaces it as a credential-decrypt failure (e.g. the resolve-credentials path returns a failed result / audit entry). There is no silent downgrade to AES — that would be a compliance-violation gate.
Abort
Identical to KEK rotation abort:
stella crypto kek abort --kek-id <id> --rotation-id <id>
Rows already re-sealed under the new algorithm remain there; rows still on the prior algorithm stay on the prior algorithm. The mixed-state is safe (per the mixed-state section above). Plan a follow-up rotation if needed.
Concurrent rotations
- The concurrency guard is per
kek_id, not per tenant (CryptoKekControlPlaneService.BeginRotationAsync→_progress.ListActiveByKekAsync(kekId)). Any second rotation on akek_idwith an in-flight rotation is refused with HTTP 409 (concurrent_rotation, surfaced by the CLI as exit code1) regardless of which tenant triggered it. The in-flight rotation must complete or be aborted first. - This means an algorithm switch for tenant A and one for tenant B that both target the same
kek_idcannot run in parallel — the second is refused until the first finishes. (A single walker pass already re-seals every tenant’s rows on thatkek_id, so a second pass is rarely needed anyway.) Switches that target differentkek_idstores are independent. - A plain KEK rotation and an algorithm switch on the same
kek_idare likewise serialized by the same guard — whichever was begun first runs to completion (or is aborted) before the other can start.
Audit & sentinel guarantees
- Plugin failures NEVER leak plaintext. Every adapter call is wrapped in try/catch that deep-scrubs
byte[]properties andDataentries from the exception graph (includingInnerExceptionandAggregateException) before propagation. ThePluginFailure_DoesNotLeakPlaintext_SentinelLeaktest asserts this with aMaliciousLeakyCapabilityfixture. - Algorithm names ARE surfaced (
SM4-GCM,GOST-GCM,AES-256-GCM); they are not secret. - Tenant compliance profile changes are audited via the
platform.update_compliance_profileaudit action (the profile-set PUT is wrapped by theAuditedfilter —AuditModules.Platform+AuditActions.Platform.UpdateComplianceProfile; the filter records before/after via theprevious_profilecolumn). This is separate from the rotation audit chain, which uses therotate_kek_*actions (rotate_kek_begin/rotate_kek_step/rotate_kek_complete/rotate_kek_abort). When the operator requested an algorithm switch, the rotate-begin audit detail block carries atarget_algorithmkey (the operator-supplied algorithm string — never key material).
Related
- ADR-008:
docs/architecture/decisions/ADR-008-credential-storage-regional-algorithm-routing.md - ADR-007:
docs/architecture/decisions/ADR-007-kek-lifecycle-and-rotation.md - KEK rotation runbook:
docs/runbooks/kek-rotation.md - Vault source runbook:
docs/runbooks/kek-source-vault.md - HSM source runbook:
docs/runbooks/kek-source-hsm.md - Sprint dossier:
docs-archive/implplan/SPRINT_20260529_028_Key_management_operations.md - Selector + primitive + profile→algorithm map:
src/__Libraries/StellaOps.Cryptography.CredentialStore/(CredentialAlgorithmProfileMap,TenantComplianceProfileCredentialAlgorithmSelector,CredentialAead) - Plugin-routed primitive:
src/__Libraries/StellaOps.Cryptography.CredentialStore.PluginRouter/ - Re-seal walker:
src/__Libraries/StellaOps.Cryptography.CredentialStore.ReSeal/CredentialReSealWalker.cs - CLI:
src/Cli/StellaOps.Cli/Commands/CryptoCommandGroup.cs(profile set --reseal,profile validate,kek …) +src/Cli/StellaOps.Cli/Services/PlatformCryptoKekClient.cs(HTTP→exit-code map) - Platform DI wiring (flag handling):
src/Platform/StellaOps.Platform.WebService/Program.cssrc/Platform/StellaOps.Platform.WebService/ConnectorCredentials/RegionalCryptoPluginServiceCollectionExtensions.cs
- Validate endpoint (
GET /api/v1/admin/crypto/profile/validate):src/Platform/StellaOps.Platform.WebService/Endpoints/CryptoProfileValidateEndpoint.cs - KEK rotate endpoints (
POST .../kek/{id}/rotate?targetAlgorithm=) + control plane:src/Platform/StellaOps.Platform.WebService/Endpoints/CryptoKekAdminEndpoints.cs,src/Platform/StellaOps.Platform.WebService/Services/CryptoKekControlPlaneService.cs - Profile-set endpoint + audit:
src/Platform/StellaOps.Platform.WebService/Endpoints/CryptoProviderAdminEndpoints.cs(AuditActions.Platform.UpdateComplianceProfile) - Scope catalog:
src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs
