LOCKED interface spec — Unified Secret Provider
- Status: LOCKED / binding for all implementers (ADR-031).
- Date: 2026-06-22
- Authority: This file defines the EXACT C# contracts. Implementers MUST NOT alter signatures without amending this spec + ADR-031.
src/is source of truth; if an existing type name here drifts from code, the code wins and this spec is amended.
All new contracts land in the existing core library StellaOps.Cryptography.CredentialStore (namespace StellaOps.Cryptography.CredentialStore) unless a path is stated otherwise. They COMPOSE — never replace — ICredentialStore, IKekSource, KekMaterial, ICredentialAlgorithmSelector (all already shipped, Sprint 027/028).
1. ISecretProvider and models
One tenant-scoped abstraction over the three backends. Seal/Open route AEAD through the per-row algorithm (ADR-008); Rotate delegates to the existing re-seal walker (ADR-007).
referenceis an opaque string supporting the existing schemes (authref://vault/<path>#<field>,vault://<path>#<field>,file://,base64:,plain:) PLUS the builtin owner-key form (builtin://<ownerKey>).
namespace StellaOps.Cryptography.CredentialStore;
/// <summary>
/// Unified, tenant-scoped secret backend facade. Backed by a concrete
/// builtin | vault | openbao provider, or by RoutingSecretProvider when
/// multi-provider routing is enabled.
/// Implementations MUST NOT log plaintext or secret bytes. Implementations MUST
/// fail loudly (throw) on backend unavailability - never silently downgrade.
/// </summary>
public interface ISecretProvider
{
/// <summary>"builtin" | "vault" | "openbao". Stamped into audit events.</summary>
string BackendKind { get; }
/// <summary>
/// Seal a tenant-scoped plaintext secret. Algorithm is resolved via the
/// injected ICredentialAlgorithmSelector unless overridden. Returns the
/// persisted reference + non-secret seal metadata.
/// </summary>
Task<SecretSealResult> SealAsync(SecretSealRequest request, CancellationToken cancellationToken);
/// <summary>
/// Resolve a secret by opaque reference. Returns null when the reference is
/// recognised but absent (NOT an exception) so callers can fail-closed
/// deterministically; throws only on backend/transport errors.
/// </summary>
Task<SecretMaterial?> ResolveAsync(SecretReference reference, ResolveContext context, CancellationToken cancellationToken);
/// <summary>Rotate a sealed secret to new plaintext (delegates to re-seal for KEK rotation).</summary>
Task<SecretSealResult> RotateAsync(SecretRotateRequest request, CancellationToken cancellationToken);
/// <summary>Revoke / delete a tenant-scoped secret reference.</summary>
Task RevokeAsync(SecretReference reference, ResolveContext context, CancellationToken cancellationToken);
/// <summary>Non-secret diagnostics: backend kind, reachability, key fingerprint/version. Never returns secret bytes.</summary>
Task<SecretProviderDescriptor> DescribeAsync(CancellationToken cancellationToken);
/// <summary>
/// Browse secret path names under an optional prefix. Returns non-secret
/// pointers only; never reads or returns secret material.
/// </summary>
Task<SecretPathPage> ListAsync(
string providerId,
string? pathPrefix,
string? pageToken,
ResolveContext context,
CancellationToken cancellationToken);
}
/// <summary>Opaque secret reference. Carries the raw scheme string + parsed parts.</summary>
public sealed record SecretReference(string RawValue)
{
/// <summary>"builtin" | "vault" | "openbao" | "file" | "base64" | "plain" | "authref".</summary>
public string Scheme { get; init; } = "builtin";
public string? Path { get; init; }
public string? Field { get; init; }
public string? OwnerKey { get; init; }
}
public sealed record ResolveContext(
Guid TenantId,
string? SourceHint = null,
IReadOnlyDictionary<string, string?>? Metadata = null);
public sealed record SecretSealRequest(
Guid TenantId,
string OwnerKey,
CredentialKind Kind,
string Name,
IReadOnlyDictionary<string, string> NonSecretMetadata,
ReadOnlyMemory<byte> Plaintext,
DateTimeOffset? ExpiresAt,
string ActorId,
string? AlgorithmOverride = null);
public sealed record SecretRotateRequest(
Guid TenantId,
SecretReference Reference,
ReadOnlyMemory<byte> NewPlaintext,
TimeSpan? GraceWindow,
string ActorId);
/// <summary>Resolved secret + non-secret metadata. Caller MUST scrub Plaintext after use.</summary>
public sealed record SecretMaterial(
ReadOnlyMemory<byte> Plaintext,
SecretReference Reference,
string KekId,
int KekVersion,
string Algorithm,
DateTimeOffset? ExpiresAt);
public sealed record SecretSealResult(
SecretReference Reference,
string KekId,
int KekVersion,
string Algorithm);
public sealed record SecretPathPage(
IReadOnlyList<string> Paths,
string? NextPageToken);
public sealed record SecretProviderDescriptor(
string BackendKind,
bool Reachable,
string KekId,
int KekVersion,
string KekSourceKind, // "env" | "file" | "vault" | "hsm" | "asymmetric"
string KekFingerprint, // non-secret fingerprint (see KekFingerprint util)
string DefaultAlgorithm);
2. Backend adapters (composition, NOT replacement)
namespace StellaOps.Cryptography.CredentialStore;
/// <summary>
/// Default floor. Composes the Sprint 027/028 machinery: tenant-scoped rows in
/// the service's own Postgres via ICredentialStore, DEKs rooted by IKekSource,
/// AEAD chosen by ICredentialAlgorithmSelector and dispatched per-row at Open.
/// Byte-for-byte preserves the existing on-disk format.
/// </summary>
public sealed class BuiltinSecretProvider : ISecretProvider
{
public BuiltinSecretProvider(
ICredentialStore credentialStore,
IKekSource kekSource,
ICredentialAlgorithmSelector algorithmSelector,
ILogger<BuiltinSecretProvider> logger);
public string BackendKind => "builtin";
// ... ISecretProvider members
}
namespace StellaOps.Cryptography.CredentialStore.Vault;
/// <summary>External HashiCorp Vault (or compatible). Reuses IVaultKvReader/Writer.</summary>
public sealed class VaultSecretProvider : ISecretProvider
{
public VaultSecretProvider(
IVaultKvReader reader,
IVaultKvWriter writer,
ICredentialAlgorithmSelector algorithmSelector,
ILogger<VaultSecretProvider> logger);
public string BackendKind => "vault";
// ... ISecretProvider members
}
namespace StellaOps.Cryptography.CredentialStore.Vault; // SAME assembly — OpenBao reuses the Vault HTTP client.
/// <summary>
/// OpenBao bundled broker. API-compatible with Vault KV v2 + login, so it reuses
/// HttpVaultKvReader/Writer with a different base URL. NO new NuGet dependency.
/// </summary>
public sealed class OpenBaoSecretProvider : ISecretProvider
{
public OpenBaoSecretProvider(
IVaultKvReader reader, // HttpVaultKvReader configured for the OpenBao address
IVaultKvWriter writer,
ICredentialAlgorithmSelector algorithmSelector,
ILogger<OpenBaoSecretProvider> logger);
public string BackendKind => "openbao";
// ... ISecretProvider members
}
3. Asymmetric KEK source
KMS-transit style. The asymmetric private key NEVER derives DEKs directly; it wraps/unwraps a symmetric intermediate KEK which is what
ResolveAsyncreturns asKekMaterial.SecretBytes. All RSA/EC + AEAD ops route through the certified crypto plugin (ADR-008).SourceKind => "asymmetric".
namespace StellaOps.Cryptography.CredentialStore;
public enum AsymmetricKeyAlgorithm { Rsa, Ec }
/// <summary>
/// Master-KEK source backed by an operator-imported PEM/PKCS#8 RSA/EC private key.
/// On first boot a strong random symmetric intermediate KEK is generated (via the
/// certified plugin), wrapped under the public key (RSA-OAEP / ECIES), and the
/// wrapped blob persisted (Crypto:Kek:Asymmetric:WrappedKekBase64). On every
/// resolve the private key unwraps that blob to recover the symmetric KEK bytes.
/// Fails startup (throws) on key parse / unwrap failure — no silent fallback.
/// </summary>
public sealed class AsymmetricKekSource : IKekSource
{
public AsymmetricKekSource(
IAsymmetricKeyUnwrapper unwrapper, // certified-plugin-backed; ADR-008
AsymmetricKekSourceOptions options,
ILogger<AsymmetricKekSource> logger);
public string SourceKind => "asymmetric";
public Task<KekMaterial> ResolveAsync(KekSourceContext context, CancellationToken cancellationToken);
}
public sealed record AsymmetricKekSourceOptions(
AsymmetricKeyAlgorithm KeyAlgorithm,
string PrivateKeyPemRef, // reference to the PEM/PKCS#8 (file:// or builtin store ref), never inline plaintext in config
string WrappedKekBase64, // the symmetric intermediate KEK, wrapped under the public key
string WrapMechanism); // "RSA-OAEP-SHA256" | "ECIES-..." (validated against plugin)
/// <summary>
/// Plugin-routed asymmetric wrap/unwrap port. Implemented by the certified crypto
/// assembly only (FIPS/GOST/SM/eIDAS). MUST scrub plaintext buffers in finally.
/// MUST NOT be implemented with raw BCL RSA/ECDsa outside the sanctioned assembly.
/// </summary>
public interface IAsymmetricKeyUnwrapper
{
/// <summary>Wrap a symmetric KEK under the operator's public key (used at provisioning/generate time).</summary>
Task<byte[]> WrapAsync(ReadOnlyMemory<byte> symmetricKek, string wrapMechanism, CancellationToken cancellationToken);
/// <summary>Unwrap the stored wrapped-KEK blob to recover the symmetric KEK (used on every boot/resolve).</summary>
Task<byte[]> UnwrapAsync(ReadOnlyMemory<byte> wrappedKek, string wrapMechanism, CancellationToken cancellationToken);
}
4. Master-key generate / import helper (symmetric + asymmetric)
Used by the Platform setup wizard’s Master Key step. Generate routes through the certified plugin’s RNG (never BCL
RandomNumberGenerator). Returns encoded material + a non-secret fingerprint for operator eyeballing.
namespace StellaOps.Cryptography.CredentialStore;
public enum MasterKeyFormat { Symmetric, Asymmetric }
public interface IMasterKeyProvisioner
{
/// <summary>
/// Generate a master key. Symmetric => high-entropy random bytes (default 32).
/// Asymmetric => RSA/EC keypair (PEM/PKCS#8). PrivateKeyPem is returned ONCE
/// for operator backup and MUST NOT be persisted in cleartext by the caller.
/// </summary>
Task<MasterKeyProvisionResult> GenerateAsync(MasterKeyGenerateRequest request, CancellationToken cancellationToken);
/// <summary>
/// Import operator-provided material. Symmetric => raw/base64 secret.
/// Asymmetric => PEM/PKCS#8 private key (validated: parseable, type, min key size).
/// </summary>
Task<MasterKeyProvisionResult> ImportAsync(MasterKeyImportRequest request, CancellationToken cancellationToken);
/// <summary>Round-trip seal/open probe against the resolved key. Throws on failure (setup gate).</summary>
Task ProbeRoundTripAsync(CancellationToken cancellationToken);
}
public sealed record MasterKeyGenerateRequest(
MasterKeyFormat Format,
int SymmetricKeyBytes = 32,
AsymmetricKeyAlgorithm AsymmetricAlgorithm = AsymmetricKeyAlgorithm.Rsa,
int AsymmetricKeyBits = 3072);
public sealed record MasterKeyImportRequest(
MasterKeyFormat Format,
string MaterialPemOrSecret); // PEM/PKCS#8 for asymmetric; raw/base64 secret for symmetric
public sealed record MasterKeyProvisionResult(
MasterKeyFormat Format,
string Fingerprint, // non-secret (KekFingerprint), safe to display/log
string? PrivateKeyPem, // asymmetric-only, returned ONCE for backup, never logged
string? WrappedKekBase64); // asymmetric-only, the wrapped intermediate symmetric KEK
5. DI registration — DISJOINT extension files
To keep the three Foundation tasks collision-free, registration is split across SEPARATE files/extension methods. No file is edited by more than one task.
| Extension method | File (owner task) |
|---|---|
AddBuiltinSecretProvider(IServiceCollection, IConfiguration) | StellaOps.Cryptography.CredentialStore/SecretProviderServiceCollectionExtensions.cs (FT-CRYPTO-CORE) |
AddVaultSecretProvider / AddOpenBaoSecretProvider | StellaOps.Cryptography.CredentialStore.Vault/SecretProviderVaultServiceCollectionExtensions.cs (FT-CRYPTO-VAULT) |
AddAsymmetricKekSource + AddSecretProviderKeySources(...) (reads Crypto:SecretProvider:Backend + Crypto:Kek:Source and dispatches) | StellaOps.Cryptography.CredentialStore/KekSourceSelectionServiceCollectionExtensions.cs (FT-CRYPTO-KEK) |
The existing KekSourceServiceCollectionExtensions.cs (env/file) is NOT edited by Foundation — AddSecretProviderKeySources calls the existing AddEnvKekSource / AddFileKekSource and the Vault/HSM sibling extensions.
Canonical config keys:
Crypto:SecretProvider:Backend = builtin | vault | openbao (default builtin)
Crypto:Kek:Source = env | file | vault | hsm | asymmetric (default env)
Crypto:Kek:Asymmetric:KeyAlgorithm = rsa | ec
Crypto:Kek:Asymmetric:PrivateKeyPemRef
Crypto:Kek:Asymmetric:WrappedKekBase64
Crypto:Kek:Asymmetric:WrapMechanism = RSA-OAEP-SHA256 | ECIES-...
Crypto:SecretProvider:OpenBao:Address
Crypto:SecretProvider:OpenBao:Auth:* (mirrors Crypto:Kek:Vault:* schema)
