Stop shipping the sops binary in your .NET images
SOPS is a good way to keep secrets in git. It encrypts the values in a YAML, JSON, dotenv, INI or binary file while leaving the keys readable, so a pull request that changes a secret still shows a meaningful diff, and th
SOPS is a good way to keep secrets in git. It encrypts the values in a YAML, JSON, dotenv, INI or binary file while leaving the keys readable, so a pull request that changes a secret still shows a meaningful diff, and the file itself is safe to commit. Reviewers can see that database.password changed without ever seeing what it changed to. Here is what that looks like in practice:
database:
username: app_user
password: ENC[AES256_GCM,data:Hk3f...,iv:...,tag:...,type:str]
sops:
age:
- recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...
-----END AGE ENCRYPTED FILE-----
mac: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
That file can live in a public repository with no loss of secrecy. The only requirement is a way to turn ENC[...] back into a usable value at runtime.
The catch is that SOPS itself is a Go command-line tool. It was built for shell pipelines and CI scripts, not for being called from inside a running application. A .NET service that wants the decrypted values has no sops.DecryptFile() method to call. It has to go around the tool, and every way of going around it has a cost.
The three workarounds and what they cost
Decrypt in CI and ship plaintext. The build pipeline runs sops -d and writes the output into the image, a configuration file, or an environment file that gets baked into the deployment artifact. This is simple and it works, but it means the secret now exists in plaintext somewhere that is not the encrypted file: in a build log, in an intermediate artifact, in the image itself if someone inspects its layers. The whole point of encrypting the file was to avoid exactly this.
Bake the sops binary into the image and shell out. The application, or an init container, invokes sops -d at startup and reads the result from standard output or a temporary file. This keeps the plaintext out of the build pipeline, but it adds a Go binary and its dependencies to what should be a .NET image, it requires a shell and a process-spawning capability that distroless and chiseled images deliberately remove, and the decrypted value still touches disk or an inherited file descriptor somewhere between the subprocess and the application.
Decrypt in the cluster into Kubernetes Secrets. A controller watches for encrypted files and writes the decrypted result into a native Kubernetes Secret, which the application then reads normally. This is better for the application code, but Kubernetes Secrets are only base64-encoded by default, and unless the cluster has encryption at rest configured, the decrypted value is now sitting in etcd.
Each of these approaches solves the problem by moving the plaintext somewhere else. None of them avoids plaintext entirely, and each one adds a moving part: a build step, a binary, or a controller. The table below lays out the full set of options and what each one costs.
| Approach |
sops binary needed at runtime |
Plaintext on disk or in a Kubernetes Secret | Extra deploy step | Works outside Kubernetes | Integrates with IConfiguration
|
|---|---|---|---|---|---|
sops -d in CI or an entrypoint script |
In the image, or on the build agent | Yes | Yes | Yes | Only through the files it writes |
.NET packages that wrap the sops and age command-line tools |
Yes | Usually a temporary file | No | Yes | Varies |
dotnet-sops (a tool for encrypting .NET user-secrets) |
Yes, on the developer machine | In the local user-secrets store | No | Local development only | Through user-secrets |
| Cluster tooling (Flux decryption, KSOPS, helm-secrets) | In the cluster tooling | Yes, as Kubernetes Secrets | Yes | No | Through environment variables or mounted files |
| A runtime secret store (Azure Key Vault, AWS Secrets Manager, Vault) | No | No | No | Yes | Yes, through a provider |
| Nops | No | No | No | Yes | Yes |
Nops reads the same files the sops CLI writes and supports the same key types, so adopting it does not change how your team edits secrets, only how the application consumes them. A fuller version of this comparison, including when one of the other rows is still the better choice, is in the project documentation.
The idea
The alternative is to decrypt inside the .NET process, at the point where the configuration system reads the file. This is what Nops does. It is a Microsoft.Extensions.Configuration provider:
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddSops("secrets.sops.yaml");
That call reads the encrypted file, decrypts the values in memory, and exposes them through the ordinary IConfiguration interface, so IOptions<T> binding, configuration layering and environment-variable overrides all work exactly as they would for any other source. The file on disk is read in its encrypted form, decrypted in memory, and the cleartext never touches disk and never spawns a subprocess. The only thing that has to reach the process is a key: an age identity, a PGP private key, or credentials for one of the supported cloud KMS services.
What it takes to do this safely
Reimplementing a decryption format is not something to do casually, and a library that gets it wrong is worse than no library at all. A few things were non-negotiable:
The file's MAC is verified before any value is returned. SOPS computes a message authentication code over the full set of values in the file, and Nops checks it before handing anything back to the caller, so a tampered file fails loudly rather than returning a partially-trusted value.
Malformed input is rejected, not guessed at. Parsers for YAML, JSON, dotenv, INI and binary formats are each tested against corrupted and truncated input, including fuzz-style mutation tests that feed broken versions of valid files through the pipeline and confirm that the result is always a clear error, never a silent partial decryption.
Nothing is written to disk. The decrypted values exist only as managed objects in memory for the lifetime of the configuration provider.
The age implementation, written in C# because no existing .NET library covered the full age specification needed here, is checked against the official C2SP test vectors, and every fixture used in the test suite is produced by the real sops binary and cross-checked against it in continuous integration. If sops encrypts something, Nops has to decrypt it the same way, and that is tested directly rather than assumed.
A walk-through
The quickest way to see this working is the project template:
dotnet new install Nops.Templates
dotnet new nops-webapi -n SecretsDemo
cd SecretsDemo
sops secrets.sops.yaml # edit with your own age key
dotnet run
The template ships with a sample encrypted file and a minimal API that reads a value from it. Swap in your own age recipient, re-encrypt, and run it again to see the value change. Run the same image with the key removed, and it fails immediately and clearly rather than starting in a broken state. This also works on a chiseled or distroless base image, which has no shell and cannot run sops even if the binary were present. There is no subprocess to invoke, so there is nothing for the missing shell to block.
Kubernetes and GitOps
The same approach extends naturally to a GitOps workflow. The encrypted file can be delivered to the pod as a ConfigMap, since it contains no secret material in a form anyone can read. The only thing that needs to be a Kubernetes Secret, with all the access control that implies, is the decryption key itself, and with a cloud KMS and workload identity, even that is not needed: the pod authenticates to the KMS directly and no key ever exists as a cluster object at all. The application can also watch the mounted file for changes and reload its configuration when the file is rotated, without a restart. A longer write-up of this pattern, including how it fits into a GitOps pipeline, is linked at the end of this article.
Honest limits
Nops only decrypts. Creating and editing SOPS files is still done with the sops CLI; this library is not a replacement for it, only a different way to consume its output. The age support currently covers X25519 recipients; passphrase, SSH and post-quantum recipients are not yet implemented. The implementation has been tested extensively against the reference tool and official test vectors, but it has not yet had an independent third-party security audit, and I want to be upfront about that rather than imply otherwise. If you work in this space and are willing to look at the code, I would welcome it.
Try it, contribute
Nops targets .NET 8 and .NET 10, is MIT licensed, and is available on NuGet as Nops.Configuration and Nops.Core, with separate packages for AWS KMS, GCP KMS, Azure Key Vault and HashiCorp Vault. The repository is at https://github.com/atulmish/Nops, the documentation with a full API reference is at https://atulmish.github.io/Nops/, and the roadmap and a set of good-first-issues are both linked from there for anyone who wants to contribute.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.
