LOCKED interface spec — Unified Secret Provider

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). reference is 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 ResolveAsync returns as KekMaterial.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 methodFile (owner task)
AddBuiltinSecretProvider(IServiceCollection, IConfiguration)StellaOps.Cryptography.CredentialStore/SecretProviderServiceCollectionExtensions.cs (FT-CRYPTO-CORE)
AddVaultSecretProvider / AddOpenBaoSecretProviderStellaOps.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)