Credential Injection
The usual way to give a process a credential is to put it in the environment. Then anything the process runs can read it, print it, or send it somewhere.
wardcell attaches the credential at the proxy, on the outbound leg, after the request has already been allowed. The sandboxed process never holds it.
[zones.user]profile = "developer"authenticated = true[plugin]path = "/usr/local/libexec/wardcell/wardcell-plugin"Where it happens
Section titled “Where it happens”sandbox → proxy: gate host → gate request → gate body → ATTACH CREDENTIAL → originInjection is last. A request the policy refuses never causes a credential to be resolved for it, let alone attached.
The plugin
Section titled “The plugin”[plugin] path names a binary wardcell execs once per run and talks to over a
Unix socket. It is asked when a connection to a host opens, and again — with the
request’s method and path — for each request over it, so that a certificate can
be cached while a per-request token is re-minted (see Caching, below).
It is asked which host, which zone is asking, and (per request) the method, path,
port and scheme, and answers one of two ways: none, or a single credential
carrying a client certificate, a list of per-request header operations, or both —
or returns an error, which fails the request rather than sending it bare. One
plugin serves every zone of every policy root on the machine, which is why the
question names the zone. Credential Plugins has
the exact bytes, every timeout, and how to test one.
The plugin decides everything: which hosts need a credential, which mechanism,
and where the value comes from. zones.toml carries no host list, no header
name, and no value — a zone that needs different treatment per host expresses
that in the plugin.
That keeps wardcell out of the business of knowing about OAuth2, SPIFFE, your secrets manager, or your internal CA. A plugin that reads one static token from a file is as small as it looks.
Headers are overwritten, never merged — and can be stripped
Section titled “Headers are overwritten, never merged — and can be stripped”Each header operation is set or remove. A set is Set, not appended: a
sandboxed process that pre-forges its own Authorization header does not get it
sent alongside — or instead of — the real one. A remove guarantees a header is
absent before the request leaves.
remove is the anti-spoofing half of a minted header. A set only governs a
request where the plugin actually minted something; on a request where it minted
nothing (an anonymous caller, a skipped mint), a header the sandbox forged would
otherwise pass through. Pairing an unconditional remove of the identity header
with a set when a token exists closes that gap.
mTLS and verifying the origin
Section titled “mTLS and verifying the origin”The sandboxed process is issued a zone-scoped stub client certificate by the per-run CA for the proxy-facing leg. The proxy presents the real identity the plugin returned on the upstream leg. The stub’s public key is only used to look up which real identity to present; it is never forwarded.
The plugin also says how to verify the origin, in the certificate’s upstream
block: ca verifies it against private PEM roots (a service signed by an internal
authority) instead of the host trust store, and insecure_skip_verify turns
verification off for a server-auth-only gateway (logged, never the default).
A header set is applied to every request, so a refreshed one is used from the
next request on. A client certificate is presented only when a connection to the
origin opens, so once its expires_at passes wardcell stops reusing that
connection: a request already in flight finishes, and the next one opens a new
connection with the refreshed identity.
Failing closed
Section titled “Failing closed”“This host needs no credential” and “the credential service is broken” are different answers and are kept apart.
none→ the request proceeds unauthenticated. A normal outcome.- A plugin that is unreachable, returns something unusable, or times out →
the request does not leave, and the caller gets a
502.
Sending it anyway would forward whatever header the sandboxed process had set in place of the identity the zone promised. A request that cannot carry its identity is not a degraded success.
Caching
Section titled “Caching”The certificate and the header set are cached independently, on two clocks
and keyed differently. The certificate is keyed by host (it depends on no
one request) and kept until near its expires_at, when the connection is
retired. The header set is keyed by the whole request — host, method, path,
port, scheme — because a minted token is bound to its request, so keying by host
alone would hand one request’s token to another; it is kept until near
headers_expires_at. An absent headers_expires_at is cached for the run; a
past one is re-asked every request, which is how a per-request minted token is
expressed; a future one caches until then. Each cache is bounded and
single-flight — concurrent callers for the same leg share one plugin call rather
than stampeding it — and neither none nor a failure is ever cached, so a cold
destination costs two calls (one certificate, one first-request header).