Signet.Client 0.6.0

dotnet add package Signet.Client --version 0.6.0
                    
NuGet\Install-Package Signet.Client -Version 0.6.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Signet.Client" Version="0.6.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Signet.Client" Version="0.6.0" />
                    
Directory.Packages.props
<PackageReference Include="Signet.Client" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Signet.Client --version 0.6.0
                    
#r "nuget: Signet.Client, 0.6.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Signet.Client@0.6.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Signet.Client&version=0.6.0
                    
Install as a Cake Addin
#tool nuget:?package=Signet.Client&version=0.6.0
                    
Install as a Cake Tool

Signet.Client (C#)

C# client for signet, generated from bytepunx/signet-proto and pinned independently of the server's own release cycle (see buf.gen.yaml).

Mirrors the Go client's design: SignetConnect.DialWorkloadAsync/DialAdmin for connection setup, and Restart.WatchBundleAsync/AcquireLockAsync/WaitForRestartAsync for coordinated rolling restarts — expressed idiomatically for .NET (Task-based async, specific exception types, IAsyncDisposable), not a mechanical transliteration of the Go syntax.

Install

dotnet add package Signet.Client

Published to NuGet automatically by publish-csharp.yml whenever release-please tags a csharp-v* release, via Trusted Publishing (OIDC — no long-lived API key stored in this repo).

Regenerating stubs

buf generate

Pulls signet/v1 and admin/v1 from buf.build/bytepunx/signet-proto and regenerates gen/ (gitignored — regenerate locally; CI regenerates fresh too). Edit the module reference in buf.gen.yaml to pin a different schema version.

Building and testing

dotnet build      # builds both SignetClient and SignetClient.Tests (via SignetClient.sln)
dotnet test        # runs the full test suite

Both commands can also be pointed at a specific project, e.g. dotnet test SignetClient.Tests/SignetClient.Tests.csproj. examples/echo is a minimal, environment-variable-configured console app used by the signet-smoke-test Docker/Kubernetes harness to exercise this library against a real signet + SPIRE cluster; it builds as part of the same dotnet build above. Requires the .NET 8 SDK; if dotnet isn't already on PATH, install it with the official script:

curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh
chmod +x /tmp/dotnet-install.sh
/tmp/dotnet-install.sh --channel 8.0 --install-dir /tmp/dotnet-sdk
export PATH="/tmp/dotnet-sdk:$PATH"

Tests use xUnit (the modern .NET default — first-class async Task test methods and Assert.ThrowsAsync for exception-type assertions, versus MSTest/NUnit's extra attribute ceremony for async cases). Every test runs against hand-written fakes (SignetClient.Tests/Fakes/FakeLockStream.cs, FakeWatchStream.cs) implementing the same internal ILockStream/IWatchStream seams the real gRPC-backed implementations use — mirroring the Go client's fake-stream pattern in restart_test.go. None of the ~32 tests open a real network connection or require a live signet instance.

Usage

Workload access (SecretsService, SPIFFE mTLS)

await using var conn = await SignetConnect.DialWorkloadAsync(
    "signet.internal:8443", "unix:///run/spire/sockets/agent.sock", "example.org");

var client = conn.SecretsClient;
var resp = await client.GetSecretAsync(new GetSecretRequest
{
    Namespace = "default", Service = "example", Name = "api-key",
});

DialWorkloadAsync retries automatically — no opt-in required — if it loses the race against SPIRE's identity-registration propagation: a brand-new pod's (a freshly-created Job's is the sharpest case) very first dial can see "no identity issued" from the Workload API even though the identity shows up moments later, since SPIRE's controller-manager reconciles the pod's SPIFFE identity registration reactively and that takes a few seconds to propagate to the node-local agent. DialWorkloadAsync probes readiness first with a single-shot fetch, retrying up to 5 attempts total with 1s/2s/4s/8s backoff, but only for that specific failure — any other error (bad socketPath, invalid trust domain, a genuine authorization problem, ...) is returned immediately, unretried. See bytepunx/signet-clients#33 and WorkloadDialRetry's doc comments in WorkloadConnection.cs for the full writeup, including why the long-lived X509Source alone can't be used to implement this (its own internal watch retries the same failure forever with an uncontrollable backoff, masking it).

Operator access (AdminService/GitOpsService, bearer token)

using var conn = SignetConnect.DialAdmin("localhost:8444", token);

var admin = conn.AdminClient;
var status = await admin.StatusAsync(new StatusRequest());

DialAdmin mirrors Go's exact TLS-selection logic: loopback addresses (the documented kubectl port-forward workflow) default to plaintext; every other address is upgraded to TLS automatically using the system trust store, or a caller-supplied CA bundle; forceTls: true requests TLS even for a loopback address. plaintext: true forces insecure transport credentials even for a non-loopback address, bypassing the loopback heuristic entirely — needed once signet exposes a real in-cluster admin listener (bytepunx/signet#19): dialing that Service by its cluster-DNS name is a non-loopback address, but the listener is intentionally still plaintext-behind-bearer-token, not TLS-terminated. Per-RPC bearer-token authentication is unaffected either way. forceTls and plaintext are mutually exclusive with each other, and plaintext is mutually exclusive with a non-empty CA bundle — DialAdmin throws an ArgumentException for either combination. See bytepunx/signet-clients#32.

Coordinated restarts (no process host, no environment injection)

WatchBundleAsync/AcquireLockAsync/WaitForRestartAsync let a service pull its own configuration directly and in-memory, and safely coordinate a fleet-wide serialized restart when it changes — without kickr (a process-host base image that injects the bundle into a child process's environment) and without ever writing secrets to the OS environment, where they'd be readable via /proc/<pid>/environ by anything sharing the pod's PID namespace.

This library deliberately does not:

  • spawn or supervise a child process — it's embedded directly in the process that's configuring itself from the bundle;
  • write the bundle to environment variables, files, or anywhere outside memory;
  • refetch the bundle after the lock is acquired — since there's no replacement process to hand it to, the next process instance (started fresh by Kubernetes after this one exits) fetches it during its own normal startup;
  • call Environment.Exit anywhere — the caller decides when to actually terminate the process.
var client = conn.SecretsClient;

// Fetch once at startup, configure the app in memory.
var bundle = await client.GetServiceBundleAsync(new GetServiceBundleRequest
{
    Namespace = "default", Service = "example",
});

// ... serve traffic ...

// Block until signet reports a change AND this replica holds the restart lock (at most one
// replica restarts at a time, fleet-wide).
var @lock = await Restart.WaitForRestartAsync(
    client, "default", "example",
    ttl: TimeSpan.FromSeconds(30),      // must cover your graceful shutdown
    debounce: TimeSpan.FromSeconds(10)); // absorb rapid successive changes

// Do your own graceful shutdown: drain in-flight requests, close resources.

await @lock.ReleaseAsync(); // release the lock for the next waiting replica
Environment.Exit(0);         // Kubernetes restarts the pod; the new process fetches fresh config

Lock.Lost is a Task<Exception> that completes, at most once, if the lock is lost unexpectedly (stream error, missed heartbeats) before you call ReleaseAsync — treat completion as "another replica may now restart concurrently." It deliberately never completes for a clean, intentional release, so await Task.WhenAny(lock.Lost, ...) is a reliable way to distinguish the two.

Heartbeats are sent at ttl / 4 — not ttl / 2. That interval matters: signet's restart-lock protocol (see docs/restart-lock.md) is explicit that "4 consecutive missed heartbeats exhaust the TTL," and a too-infrequent heartbeat was a real bug previously found in kickr, signet's existing process-host client.

SPIFFE support: a maturity gap worth knowing about

DialWorkloadAsync is implemented against the third-party Spiffe NuGet package (vurhanau/csharp-spiffe) — a C# reimplementation of go-spiffe (the library Go's DialWorkload depends on). Its API maps directly onto go-spiffe's: X509Source fetches and rotates an X.509 SVID from the Workload API, and Authorizers.AuthorizeMemberOf(trustDomain) rejects a server whose presented SPIFFE ID isn't a member of the expected trust domain — the same semantics as Go's tlsconfig.AuthorizeMemberOf.

Unlike go-spiffe, this is not an official spiffe.io project. As of this writing it's a single-maintainer repository at a pre-1.0 version (0.0.3), with modest adoption (order of a dozen GitHub stars). It is actively maintained — commits and dependency bumps within the last month — and its design is a faithful, well-structured port of go-spiffe, but teams adopting DialWorkloadAsync should weigh that maturity gap before relying on it in production, and pin the exact version rather than trusting broad semver ranges. Unlike the Erlang client, which has no workable path to codegen'd protobuf/gRPC stubs at all yet, C#'s gap is narrower: only the SPIFFE Workload API integration is on comparatively thin ice — DialAdmin, WatchBundleAsync, AcquireLockAsync, and WaitForRestartAsync have no SPIFFE dependency whatsoever and work against any Grpc.Net.Client.GrpcChannel, including one a caller constructs by hand (e.g. against a self-managed mTLS setup) without going through DialWorkloadAsync at all.

Despite that maturity gap, Spiffe turned out to expose exactly the primitives DialWorkloadAsync's SPIRE-registration-propagation retry (bytepunx/signet-clients#33, described above) needs: IWorkloadApiClient.FetchX509ContextAsync is a genuine single-shot, non-watching fetch that surfaces the raw gRPC status on failure, distinct from the long-lived WatchX509ContextAsync-backed X509Source — the same split Go's go-spiffe offers between workloadapi.FetchX509Context and workloadapi.NewX509Source. So unlike Python's workload-mTLS gap (python/README.md), which blocks dial_workload outright because grpc-python has no public API to validate a SPIFFE-URI-SAN-only server certificate, this retry didn't require a documented gap or a fragile workaround — the library genuinely supports it.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.6.0 102 9/3/2026
0.5.0 115 8/15/2026
0.4.0 112 8/8/2026
0.3.0 113 7/20/2026