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.*/:

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

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-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:

The console-admin /console-admin/crypto-control/validate page surfaces the same outcome with a FIPS-warning badge.

Consequences

Positive

Negative

Neutral

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

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