View on GitHub

Smart Application Framework (SAF)

Open-source, cross-platform framework for distributed applications across cloud and edge.

Secret Store

SAF’s secret store keeps sensitive configuration values — usernames, passwords, tokens, key passphrases — out of your configuration files and in a secure, OS-level store instead. Plug-ins read and write secrets through a single injected service, ISecretStore, without knowing where or how the secrets are physically stored.

Status. The store, provider selection, transparent secret:// configuration resolution, the Windows Credential Manager provider and the cross-platform file-based provider are available today. A systemd-credentials provider is planned — see Roadmap.

Why

Storing credentials in configuration files (even obfuscated) means the secret travels with every copy of the file — into source control, backups, support bundles and other machines. The secret store moves the secret into an OS-managed vault that is bound to a single security principal on a single machine, so a leaked configuration file no longer leaks the credential.

Security model

A credential store does not promise “nobody can read the secret” — it promises “only a principal with sufficient privilege can”. Understanding that boundary is important:

The right operational posture is to run the process under a least-privileged identity and let that identity own the secrets.

Packages

Package Purpose
SAF.Configuration.Secrets.Contracts Interfaces and types: ISecretStore, ISecretReader, ISecretWriter, ISecretStoreProvider, ISecretProtector, SecretStoreOptions, FileSecretStoreOptions, SecretReference
SAF.Configuration.Secrets Provider implementations (Windows Credential Manager, file store), the default PkcsSecretProtector, and provider selection
SAF.Configuration.Secrets.Extensions Plugin-system host-builder integration (AddSecretStore, AddSecretConfigurationResolution)

Reference SAF.Configuration.Secrets.Extensions from your host; it pulls in the other two.

Getting Started

1. Register the secret store on the host

AddSecretStore is an extension on the plugin system host builder. It registers the store with the built-in providers for the current platform and forwards ISecretStore into every plug-in container, so any plug-in can inject it.

using SAF.Configuration.Secrets.Extensions;

builder.AddSafHost()
    .ConfigurePluginSystem(ps =>
    {
        ps.AddPluginAssemblyFolderContainer(options =>
        {
            options.SearchRootPath = AppContext.BaseDirectory;
            options.IncludePatterns = "MyApp.Plugin.*.dll;SAF.Messaging.InProcess.dll";
            options.Recursive = false;
        });

        ps.AddSecretStore(options => options.Namespace = "myapp");
    });

2. The contracts assembly is shared automatically

ISecretStore is forwarded from the host into every plug-in container via IHostServiceForwarder (see Plugin System: IHostServiceForwarder). For a plug-in to accept the forwarded instance, its isolated load context must resolve ISecretStore to the same SAF.Configuration.Secrets.Contracts assembly the host uses — the plugin system does this automatically for any assembly it finds in the host’s own base directory. Referencing SAF.Configuration.Secrets.Extensions from your host project is normally all it takes: the contracts assembly is a transitive dependency, so the build already places it next to your host executable.

Do not add it to PluginContractsSearchPattern — that setting controls a different mechanism, discovering cross-plugin service exports (see Cross-Plugin Services), not host-to-plugin forwarding. Adding it here additionally registers ISecretStore as an exported cross-plugin service, which is not what you want.

3. Inject and use ISecretStore in a plug-in

using SAF.Configuration.Secrets.Contracts;

public sealed class OpcUaConnection(ISecretStore secrets)
{
    public async Task ConnectAsync(CancellationToken ct)
    {
        var user = await secrets.GetSecretAsync("opcua/connection-1/user", ct);
        var password = await secrets.GetSecretAsync("opcua/connection-1/password", ct);

        if (user is null || password is null)
            throw new InvalidOperationException("OPC UA credentials are not provisioned.");

        // ... open the session with user / password ...
    }
}

The API is small:

namespace SAF.Configuration.Secrets.Contracts;

public interface ISecretStore : ISecretReader, ISecretWriter;

public interface ISecretReader
{
    Task<string?> GetSecretAsync(string name, CancellationToken cancellationToken = default);
}

public interface ISecretWriter
{
    Task SetSecretAsync(string name, string value, CancellationToken cancellationToken = default);
    Task RemoveSecretAsync(string name, CancellationToken cancellationToken = default);
}

GetSecretAsync returns null when the secret does not exist; RemoveSecretAsync succeeds even when it does not.

Providers

Each backend is an ISecretStoreProvider. More than one provider can be available on the same platform (for example, Windows can offer both the Credential Manager and the file store); each provider decides via IsAvailable whether it applies to the current environment.

Provider Name Availability
Windows Credential Manager windows-credential-manager Windows only. Stores secrets as generic credentials in the running identity’s vault (per-principal isolation).
File store file Cross-platform. Persists secrets to a single JSON file, each value encrypted at rest through an ISecretProtector. Reports itself unavailable until a protector is registered (see below).

On non-Windows platforms the file store is the built-in default, but it only becomes available once you register an ISecretProtector (there is no OS-integrated at-rest encryption to fall back on). See The file store and its protector.

Selecting providers

Two independent axes control which provider is used:

  1. Registration — which providers are candidates (you choose, in priority order).
  2. Selection at runtimeSecretStoreOptions.ProviderName:
    • "auto" (default) picks the first available provider in registration order.
    • a specific name (e.g. "windows-credential-manager") forces that provider.

One provider answers, and it is not a fallback chain. Selection happens once; the chosen provider serves every read and every write for the process lifetime. A name it does not hold is absent — the next registered provider is never consulted for it. That keeps provisioning and resolution on the same backend, and keeps “which store answered?” a question with one answer. Registration order therefore decides which provider is used here, not what is tried after a miss.

Only a failed selection is retried: if no provider was available on the first attempt, the next call selects again, so a provider whose availability is a runtime fact (a remote vault) can recover without a restart. Configuration resolution treats obtaining the store the same way: when that fails during a reload, the previously resolved values are kept and the next reload tries again, instead of latching the failure for the process lifetime.

Default registration

Omitting the provider callback registers all built-in providers for the platform in a documented priority (OS-native store before the file store):

ps.AddSecretStore(o => o.Namespace = "myapp"); // = AddDefaults()

Explicit registration

Pass a provider callback to register exactly the providers you want, in priority order. The order of the calls is the priority used by "auto":

ps.AddSecretStore(
    configure: o => o.ProviderName = "auto",
    configureProviders: providers => providers
        .AddWindowsCredentialManager());

Custom providers

Add your own backend (for example a remote key vault) without modifying the framework — implement ISecretStoreProvider and register it:

ps.AddSecretStore(null, providers => providers
    .AddProvider<MyKeyVaultProvider>()   // used when available
    .AddWindowsCredentialManager());     // used only if the vault provider is unavailable

A provider receives the physical target nameNamespace already prepended, the whole key lower-cased — not the logical name the reference carried. The convention is applied once, by the composite store, before it delegates: a custom provider inherits it without knowing about it, and must store the name it is given rather than applying Namespace a second time. For the same reason, resolve and call ISecretStore; an ISecretStoreProvider resolved directly would be handed logical names it is not meant to interpret.

The file store and its protector

The file provider persists secrets to a single JSON file. Unless FileSecretStoreOptions.Path is set, it defaults to a per-machine data location — %ProgramData%\<namespace>\secrets.json on Windows, /var/lib/<namespace>/secrets.json elsewhere — not the host application directory: per Plugin Deployment Security, that directory must be read-only to the runtime account, which rules it out as a location this provider writes to. The logical names stay in clear — a secret reference is not itself sensitive — while each value is encrypted at rest through an injected ISecretProtector.

The protector (and its key/certificate material) is not registered for you: it is a deployment decision, so you register it explicitly. The built-in, cross-platform default is PkcsSecretProtector (PKCS#7/CMS enveloping: AES-256 for the value, RSA-OAEP-SHA256 for the key, keyed by an X.509 certificate you supply):

using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.DependencyInjection;
using SAF.Configuration.Secrets;
using SAF.Configuration.Secrets.Contracts;
using SAF.Configuration.Secrets.Extensions;
using SAF.Configuration.Secrets.Protection;

// Register a protector, then the file store. On non-Windows AddDefaults() already registers the file
// store, so registering the protector alone is enough there.
ps.Services.AddSingleton<ISecretProtector>(_ => new PkcsSecretProtector(certificate));

ps.AddSecretStore(
    configure: o => o.Namespace = "myapp",
    configureProviders: providers => providers.AddFile(o => o.Path = "/var/lib/myapp/secrets.json"));

certificate needs its private key on the service host (Unprotect requires it) but only its public key on a provisioning/installer host (Protect works either way). How you obtain it matters:

Key points:

Windows alternative (planned). A DPAPI-backed protector can be added additively for Windows-only file stores without changing the store — see Roadmap.

Transparent configuration resolution

Besides injecting ISecretStore directly, SAF can resolve secrets transparently in configuration: put a reference instead of the value in your plugin configuration, and existing IConfiguration/Bind-based plug-ins receive the real secret with no code change.

Enable it on the host builder (compose it with AddSecretStore, or use it on its own):

ps.AddSecretConfigurationResolution(o => o.Namespace = "myapp");

Then reference secrets in the plugin configuration with the secret:// prefix:

{
  "OpcUaConnections": [
    {
      "User": "secret://opcua/conn-1/user",
      "Password": "secret://opcua/conn-1/password",
      "Host": "opc.tcp://plc-1:4840"
    }
  ]
}

A reference does not repeat the namespace. It carries the logical name only; the store prepends the configured Namespace to form the physical key (see Secret names). With o.Namespace = "myapp", secret://opcua/conn-1/password is looked up as myapp/opcua/conn-1/password. Writing secret://myapp/opcua/conn-1/password instead would look up myapp/myapp/opcua/conn-1/password and fail to resolve.

Composing the two calls. Both register the same store, so the order of the two calls does not matter and calling either twice changes nothing. Options callbacks from both are applied in call order, so a property set in both takes the value from the later call.

The provider list is the exception. The order providers are registered in is the priority order ProviderName = "auto" selects from, and registration appends: a second list would land behind the first rather than replace it, leaving the store reading from a backend nobody chose. So pass configureProviders to one call only — the other takes what is already registered:

ps.AddSecretStore(o => o.Namespace = "myapp", p => p.AddFile());
ps.AddSecretConfigurationResolution();   // same store, same options, same providers

Passing it to both throws an InvalidOperationException during registration, naming both calls, instead of letting the call order decide the active backend. A call that omits it accepts whatever is already registered — including providers registered directly, e.g. services.AddSecretStore().AddFile() — and registers the platform defaults only when nothing is.

Registration order does not matter, and a reference is never handed out unresolved. Every plugin configuration source is resolved, whether its AddPluginConfigurationSource callback ran before or after AddSecretConfigurationResolution. Resolution reads the composed plugin configuration once every source has been built, and the resolved values are layered on top of it — so a settings file registered by a later callback is resolved like any other, and each file is still parsed and watched exactly once.

The standalone IConfigurationBuilder.AddResolvedSecrets(...) has no such control over when the root is built. It resolves against the builder’s sources as they stand at Build() time, which costs a second materialization of those sources, and it cannot override a source added after it — those providers answer first. A reference coming from such a source therefore throws at startup, naming the configuration keys, rather than passing the literal secret://… token to the consumer as its credential. Call AddResolvedSecrets last to avoid it.

That guarantee needs a builder that defers building until Build(). ConfigurationManager — the builder behind Host.CreateApplicationBuilder().Configuration and WebApplication.CreateBuilder().Configuration — instead builds every source as soon as it is added, so the resolver could see neither the later sources nor the fact that they shadow it. AddResolvedSecrets therefore refuses a ConfigurationManager with a NotSupportedException instead of failing open. Compose and resolve in a ConfigurationBuilder, then chain the built root in:

var resolved = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json")
    .AddResolvedSecrets(o => o.Namespace = "myapp")
    .Build();

builder.Configuration.AddConfiguration(resolved);

For plug-in configuration, prefer AddSecretConfigurationResolution — it resolves against the composed root and has none of these ordering constraints.

Enabling resolution does not change unrelated settings. A value that is not a secret:// reference is served exactly as the undecorated configuration would serve it, empty strings included: a "Suffix": "" an operator configured deliberately still reads as "", not null. Only values that parse as a reference are replaced.

How resolution reaches the host container. Plugin configuration is built inside the same factory that constructs IPluginSystemHostContext, which the plugin system only ever invokes once the host’s IServiceProvider is fully built (see Plugin System: Plugin Settings). The resolver receives that provider and reads the ISecretStore and SecretStoreOptions registered on it directly — there is no separate bootstrap phase, and no action is required to make that happen.

Options

SecretStoreOptions (configure via the AddSecretStore / AddSecretConfigurationResolution callback):

Option Default Meaning
ProviderName "auto" Which provider is active. "auto" = first available in registration order; or a provider name to force it.
Namespace "saf" Prepended to every secret name to form the store key, so different products/hosts do not collide.
ReferencePrefix "secret://" Marks a configuration value as a secret reference (transparent resolution).
AllowEnvironmentOverride false When resolving a reference, check a derived environment variable before the store. See Environment overrides.
EnvironmentVariablePrefix "SECRET" Prefix of that environment variable.
ThrowOnUnresolvedReference true Throw when a secret:// reference cannot be resolved, instead of passing it through as null.
ResolveTimeout 30s Upper bound on resolving all references of one configuration load. Configuration loads synchronously, so without it an unreachable store hangs startup with no diagnostic. Timeout.InfiniteTimeSpan waits forever. Enforced by bounding the wait itself, so it also applies to a provider that blocks in a synchronous call and never observes cancellation.

FileSecretStoreOptions (configure via providers.AddFile(o => ...), or services.AddFileSecretStore(o => ...)):

Option Default Meaning
Path per-machine data location Filesystem path of the store file. See The file store and its protector.
LockTimeout 30s Upper bound on waiting for another process to release its exclusive hold on the store file, after which the operation throws a TimeoutException. Timeout.InfiniteTimeSpan waits forever.

Secret names

A secret name is a logical key such as opcua/connection-1/password. The active Namespace is prepended to form the physical store key (e.g. myapp/opcua/connection-1/password). Names are not secret and may be committed to configuration and source control.

The physical store key is case-insensitiveNamespace and the name are both lower-cased before use, once, for every provider alike. This matches the Windows Credential Manager, which treats target names case-insensitively regardless of what is written; without normalizing, the same logical secret could resolve differently depending on which backend is active.

The file store also matches keys case-insensitively when it reads, so a secrets.json provisioned by hand or by an installer resolves whatever case its keys were written in, and a later write updates that entry instead of adding a second one beside it.

Environment overrides

Setting AllowEnvironmentOverride = true makes resolution check an environment variable before the store, so CI and development can supply secrets on a host with no OS keystore and no provisioned file:

ps.AddSecretConfigurationResolution(o =>
{
    o.Namespace = "myapp";
    o.AllowEnvironmentOverride = true; // opt in — off by default
});

The variable name is EnvironmentVariablePrefix, then __, then the physical store key (so the Namespace is included, exactly as in Secret names), with /__ and every other non-alphanumeric character → _. The key is lower-cased first, so the derived name is too:

Namespace reference variable
myapp secret://opcua/conn-1/password SECRET__myapp__opcua__conn_1__password
other secret://opcua/conn-1/password SECRET__other__opcua__conn_1__password

Because the namespace is part of the name, two hosts sharing one environment but using different namespaces do not share an override variable. On Linux, where environment lookup is case-sensitive, note the lower-casing: secret://Db/Password reads SECRET__myapp__db__password.

The derivation is readable, not injective. / becomes __ while every other non-alphanumeric character becomes a single _, so two names can map onto one variable: under namespace myapp, both a/b and a--b yield SECRET__myapp__a__b, and both a-b and a.b yield SECRET__myapp__a_b. Setting that variable overrides every name mapping to it. Readable names were preferred over injective ones because an operator types these by hand; the risk only becomes real if two of your secret names differ solely in their non-alphanumeric characters, so avoid that pairing, or leave overrides off.

Because the override is checked before the store, it wins over a correctly provisioned keystore entry unconditionally, and the value it supplies never passes through the store’s at-rest protection. That is what makes enabling it a widening of the trust boundary to everyone who can set the process environment, and why it is off by default. When an override is applied the resolver logs it at Debug with the reference and the variable name (never the value).

Provisioning secrets

Secrets must exist in the store before the service reads them. Use ISecretStore.SetSecretAsync from your own tooling/installer, or provision them out-of-band (for the Windows Credential Manager, under the identity the service runs as). SAF intentionally contains no migration logic — moving existing in-file secrets into the store is the responsibility of each product.

Roadmap

The following are planned and not yet available: