ADR-008: Credential Storage Regional Algorithm Routing
Status: Accepted Date: 2026-05-27 Sprint: SPRINT_20260529_028_Key_management_operations.md (RP-028-009…013, RP-028-014) Related ADRs: ADR-004 (forward-only migrations), ADR-007 (KEK lifecycle and rotation — same operator-control surface) Related runbooks: credential-storage algorithm switch, KEK source: Vault, KEK source: HSM
This record lets regulated installations seal credential ciphertext under their region-appropriate AEAD (eIDAS / FIPS / GOST / SM) by routing the raw primitive through the regional crypto plugins, while keeping HKDF key-derivation and AAD binding inside canonical code so the on-disk format never fragments. Read it before touching CredentialAead, the credential-storage primitive seam, or a tenant compliance profile.
Context
After Sprint 027, CredentialAead — the canonical seal/open primitive for the three credential-ciphertext stores (Platform connector credentials, Integrations inline credentials, ReleaseOrchestrator deployment bundles) — was hardcoded to AES-256-GCM. The cryptographic primitive call was inlined directly against IAeadAlgorithm / DefaultAeadAlgorithm.Instance.
This collided with the rest of the repo’s crypto posture. The repository ships six regional crypto plugins under src/Cryptography/StellaOps.Cryptography.Plugin.*/:
StellaOps.Cryptography.Plugin.Fips— FIPS 140-3 validated AES-GCMStellaOps.Cryptography.Plugin.Eidas— eIDAS-aligned algorithm suiteStellaOps.Cryptography.Plugin.Gost— GOST R 34.12-2015 / KuznyechikStellaOps.Cryptography.Plugin.Sm— SM4-GCM (ShangMi / Chinese national)StellaOps.Cryptography.Plugin.Hsm— PKCS#11 unwrap + signStellaOps.Cryptography.Plugin.OpenPgp— OpenPGP envelope
Each plugin inherits CryptoPluginBase : IPlugin, ICryptoCapability and participates in the ICryptoProviderRegistry / IAeadAlgorithm resolution chain for token encryption, federation, and signature operations.
The credential store ignored the chain entirely. Every install — regulated or not — sealed credentials under AES-256-GCM regardless of the operator’s tenant compliance profile. For operators in regulated regions (eIDAS, FIPS, GOST R, SM/ShangMi) this meant their connector credentials, integration credentials, and deployment bundles were stored under an algorithm their regulator does not recognise, even when the regional plugin was loaded and selected for every other crypto operation in the system.
Decision
Adopt the hybrid routing model (option c): extract the raw AEAD primitive behind a small abstraction (ICredentialAeadPrimitive), let an opt-in plugin-routed implementation dispatch by algorithm string, but keep HKDF key-derivation and AAD binding inside CredentialAead itself so the on-disk format does not fragment across regional implementations.
The primitive seam
interface ICredentialAeadPrimitive
{
SealResult Seal(string algorithm, ReadOnlySpan<byte> key,
ReadOnlySpan<byte> nonce, ReadOnlySpan<byte> plaintext,
ReadOnlySpan<byte> aad);
ReadOnlyMemory<byte>? Open(string algorithm, ReadOnlySpan<byte> key,
ReadOnlySpan<byte> nonce, ReadOnlySpan<byte> ciphertext,
ReadOnlySpan<byte> aad);
}
Two implementations, both ship
IAeadAlgorithmCredentialPrimitive(default) — routes through the existingIAeadAlgorithm. Produces byte-identical output to Sprint 027 for AES-256-GCM rows. The load-bearing regression sentinelSeal_RoutesThroughPrimitive_DefaultBinding_BitIdenticalToPreRefactor_AesGcm256asserts this byte-for-byte. Default DI binds this implementation; Sprint 027 callers compile and behave unchanged.PluginRoutedCredentialAeadPrimitive(opt-in viaCrypto:CredentialStore:PrimitiveRouter=plugin) — resolves anICredentialAeadPluginAdapterper algorithm string via per-algorithm shim adapters (Sm4GcmAdapter,AesGcmFipsAdapter,GostGcmAdapter). The shims translate(key, nonce, plaintext, aad)ontoICryptoCapabilityviaCryptoEncryptOptions { Algorithm, KeyId = "credential-store:inline:<base64(key)>", Iv = nonce, Aad = aad }per the cooperative-plugin convention. Cached per algorithm string for hot-path performance.
HKDF + AAD stay in CredentialAead (the load-bearing decision)
The per-tenant DEK derivation (HKDF over the master KEK + tenant_id + nonce) and the AAD shape (store_prefix || tenant_id || row_id) are computed by CredentialAead itself and passed into the primitive. The plugin layer sees (algorithm, derived_key, nonce, plaintext, aad) — never the master KEK, never the raw tenant identifier separately from the AAD.
This is the load-bearing choice. If the plugin owned HKDF too:
- Per-tenant DEK derivation would fragment across regional implementations; two installs with the same KEK but different algorithm choices would have different on-disk shapes, blocking re-seal-across-algorithms.
- Sprint 027’s byte-identity invariant (
ConnectorCredentialAeadTests10/10) could not be enforced post-refactor; the regression sentinel only works because HKDF is still computed in canonical code. - AAD binding correctness — the property that “row R from tenant T cannot be opened against tenant T’” — would become a plugin-conformance test rather than a single invariant in canonical code.
Per-row algorithm dispatch
Every ciphertext row carries credential_alg (Sprint 027 default AES-256-GCM on existing rows via additive backfill). Seal-time algorithm is sourced from the active tenant compliance profile via ICredentialAlgorithmSelector + DefaultCredentialAlgorithmSelector (maps tenant → algorithm; defaults to AES-256-GCM when no profile set or when the profile is world / fips / eidas / kcmvp). Open-time algorithm is read from the row’s persisted credential_alg column and passed to the primitive verbatim. Rows sealed before an algorithm switch continue to decrypt under their original algorithm forever; the CredentialReSealWalker supports --switch-algorithm for operator-initiated re-seal of an entire tenant’s rows.
Validation surface
stella crypto profile validate and the GET /api/v1/admin/crypto/profile/validate endpoint run a dry-run seal+open round-trip via the configured primitive with a sentinel plaintext, returning one of:
okprofile_inactive— selector resolved to a profile the system does not recogniseno_plugin_matches— plugin routing enabled but no adapter registered for the resolved algorithmfips_disagreement— FIPS plugin loaded +RequireFipsMode=true+ host not FIPS-validatedroundtrip_failed— primitive failed to round-trip the sentinel (the catch-all)
The console-admin /console-admin/crypto-control/validate page surfaces the same outcome with a FIPS-warning badge.
Consequences
Positive
- Regulated installations get region-appropriate AEAD for credential storage without re-implementing the storage layer. eIDAS / FIPS / GOST / SM operators flip
Crypto:CredentialStore:PrimitiveRouter=plugin+ set the tenant compliance profile + runstella crypto profile set <name> --resealand the entire tenant’s credentials migrate to the regional algorithm. - Backwards compatibility preserved forever. Existing AES-GCM rows continue to read after any algorithm switch — the per-row
credential_algcolumn is authoritative on Open. Operators can freely run mixed-algorithm tenants during a transition without data loss. - Plugin layer stays free of HKDF / AAD correctness concerns. The plugin conformance suite remains “implement AEAD correctly for this algorithm” — it never needs to know about per-tenant derivation.
- The validate endpoint + CLI + UI page give operators a one-shot pre-rotation check that the plugin chain is wired correctly for the profile they have selected.
Negative
- More plugin surface in the credential-storage hot path. The plugin-routed primitive cache adds one dictionary lookup per Seal/Open; benchmarks show this is sub-microsecond compared to the AEAD operation itself, but it is a net new code path.
- Plugins must be FIPS-mode-aware. The validate surface catches
RequireFipsMode=true+ non-FIPS host disagreements at config time, but operators must runstella crypto profile validateafter every plugin change. - Mixed-algorithm tenant state during a transition is operationally novel. Some rows on AES-GCM, some on SM4-GCM, both opened correctly — this is surfaced in the UI via per-algorithm chip (“AES-256-GCM: 47 | SM4-GCM: 12”) and in the validate page so operators have a clear “we are mid-transition” signal. The runbook
credential-storage-algorithm-switch.mddocuments this explicitly. - The default-impl path through
IAeadAlgorithmstill uses BCLAesGcminside the canonical crypto assembly. This is the same posture as Sprint 027 — the conformance allow-list (AeadBclCallSiteConformanceTests) records both the legitimateChaCha20Poly1305enum-token reference and the in-memoryAesGcmunwrap fixture inHsmKekSourceTests.
Neutral
- Architecture-conformance tests continue to prove no direct BCL AEAD call sites exist outside the canonical crypto assembly + the documented allow-list. The plugin adapters route through
ICryptoCapability, never through BCL primitives directly.
Alternatives considered (rejected)
Plugin owns HKDF too
Rejected. Would put per-tenant DEK derivation under plugin correctness, fragment the on-disk format across regional implementations, and break the Sprint 027 byte-identity invariant. The plugin conformance burden would grow from “implement AEAD” to "implement AEAD + match canonical HKDF parameters
- match canonical AAD shape" — three independent ways to silently break cross-install compatibility.
Replace IAeadAlgorithm wholesale with ICryptoCapability-routed primitives
Rejected. IAeadAlgorithm has many non-credential callers (token encryption, federation, signature envelopes). Refactoring all of them is too broad for this sprint and would land a regression risk far larger than the gap being closed.
Add SM4-GCM directly to IAeadAlgorithm’s default implementation
Rejected. Would require a BouncyCastle dependency in the canonical crypto assembly. BouncyCastle is a perfectly reasonable transitive dependency for the Sm plugin, but pulling it into the canonical assembly puts it on every service’s dependency closure regardless of whether they care about regional algorithms. The plugin layer is exactly the boundary that keeps regional-algorithm dependencies out of the core graph.
Per-tenant primitive selection (different primitive for different tenants in the same process)
Rejected for this sprint. The primitive is process-scoped; the algorithm is per-tenant (via the selector + the per-row credential_alg column). A future sprint could add per-tenant primitive selection if a single install needs to host tenants in different compliance regions simultaneously, but no operator has asked for that today and the routing surface is already there if needed.
Auto-rotate algorithm on tenant compliance profile change
Rejected. The walker is the right place for that operation, and operators explicitly drive it (stella crypto profile set <name> --reseal). Auto-rotation on every profile-edit would be a foot-gun: an exploratory profile change in dev would silently re-seal every row.
References
- Sprint 028 dossier:
docs/implplan/SPRINT_20260529_028_Key_management_operations.md(ordocs-archive/implplan/...after closeout). - Commits:
3c6ccf23fcRP-028-009ICredentialAeadPrimitiveabstraction + default impl72b4087187RP-028-010PluginRoutedCredentialAeadPrimitive+ per-algorithm adapters4044ec7961RP-028-011credential_algdispatch +ICredentialAlgorithmSelectorcd818c05fbRP-028-012 walker--switch-algorithm+ CLIprofile set --resealcb2a09fe32RP-028-013 algorithm display chip + profile validate endpoint
- ADR-004: Forward-Only Database Migrations
- ADR-007: KEK Lifecycle and Rotation — the operator-control surface this ADR shares.
