Skip to content

Add the external key custodian sample - #45

Merged
kirill-abblix merged 19 commits into
masterfrom
feature/external-key-sample
Sep 6, 2026
Merged

Add the external key custodian sample#45
kirill-abblix merged 19 commits into
masterfrom
feature/external-key-sample

Conversation

@kirill-abblix

@kirill-abblix kirill-abblix commented Sep 6, 2026

Copy link
Copy Markdown
Member

A provider whose signing and decryption keys live in an external custodian - a Vault / OpenBao Transit engine or Azure Key Vault - so the private half never enters the application's memory. The server publishes only public JWKs; every private operation is a call out.

The branch was written while the packages carrying that seam were still unreleased, so it pointed at a sibling library checkout and a private feed. Both are gone: it now takes Abblix.Oidc.Server.MinimalApi, Abblix.Jwt.Vault and Abblix.Jwt.Azure at 2.4.0 from nuget.org, which is the first state in which somebody who clones this repository alone can build it.

What it shows

The sample carries no custodian code - the packages do. What it demonstrates is the placement choice, both ways round. UseKeysIn=Custodian sends every private operation to the custodian and publishes only the public halves. UseKeysIn=Process mints the signing key locally, seals it to a key-encryption key and keeps the sealed ring in the custodian's own store. The kill switch behaves oppositely in each, which is what makes the trade visible rather than described.

What was run

Restored the way a reader would have it, with only nuget.org configured and an empty package folder: the Abblix packages resolve at 2.4.0 and the build carries no warnings.

Then the provider was started against the compose file's OpenBao and driven end to end.

Claim in the README What the run showed
client_credentials returns an RS256 JWS three parts, alg RS256
the JWKS is public-only n and e present, d and p absent
any service verifies with the public key alone signature verifies from the JWKS; a tampered input is rejected
sealing the custodian stops signing 500, no token
with encryption on, the token is a JWE and introspection unwraps it five parts, RSA-OAEP-256, introspection returns active: true
the process tier keeps issuing while the custodian is sealed 200, and the kid is the minted key's thumbprint
a wrong client secret is refused 401 invalid_client
an absent setting stops the server naming that setting driven for every required setting, including the ones checked inside the options delegate

Two README claims did not survive that: the headers carry oidc-sign:1 and oidc-enc:1, not the bare key names. The same page already said further down that a bare key name is never a kid, so the two examples contradicted their own explanation. Both now print what the server prints.

Configuration carries the values, and the code carries none

No value the sample cannot invent has a default in code. That started with the client secret - a default for its hash is credential material in code, in the one sample whose subject is keeping private material out of the application - and extends to the rest, because a default beside appsettings.json is a second copy that drifts.

required does not achieve this on its own: the configuration binder ignores it, and an absent setting then surfaces deep inside whatever reads it first, naming a framework parameter. So each value is checked where it is read, by a call that names the section and the member. Checking them in a list up front was tried and removed: several are read in one posture only, and the list refused configurations that were complete for the posture in use.

The Azure section carries no credential fields. The package accepts a service principal there, and the README says so and names the settings, but a sample is not the place to ship a labelled slot for a secret.

The sealed-custodian failure

The token endpoint answers 500. RFC 6749 section 5.2 lists the error codes that endpoint may return and an unreachable key store is none of them; server_error belongs to the authorization endpoint, which needs it because a 500 cannot travel through a redirect. What the body carries depends on the environment, and the README now says so: the launch profile selects Development, where the exception and its stack trace are returned, while Production answers a bare 500. Neither is a shaped error, and that is the library's behaviour rather than the sample's.

Also here

master was merged in. Both sides had independently added a checksum verification for the actionlint download; master's is on the newer version, so its side was kept whole.

…ey custodian

A slim OIDC provider whose signing and encryption keys live in a Vault / OpenBao
Transit engine or Azure Key Vault, never in process memory. The client_credentials
grant issues service tokens signed inside the custodian; the public-only keys back
the JWKS and local verification, and the custodian is selected by configuration.

Ships a docker-compose that provisions OpenBao Transit, the Vault and Azure adapters
behind IExternalSigner / IExternalKeyEncryptor, and a README with the run and
verification steps including the encryption path via introspection.

Held until 2.4: references the 2.4 dev package that first carries the external-key seam.
The Azure custodian builds a ClientSecretCredential when TenantId, ClientId and
ClientSecret are set in the Azure config section, and falls back to
DefaultAzureCredential (managed identity, or the AZURE_* environment variables)
otherwise. appsettings carries blank placeholders so the secret stays out of
source control.

Verified end to end against a live Azure Key Vault: RS256 signing and
RSA-OAEP-256 unwrap both work, JWKS is public-only, and the signature checks out
against the published key.
A ready-to-run HTTP request collection (token, JWKS, introspection) for the VS
Code REST Client, JetBrains IDEs, Postman, Bruno, or Hoppscotch. The token
request is named so the introspection call chains its access_token.
Replace the REST Client response-chaining syntax, which JetBrains IDEs do not
share, with a plain @accesstoken variable the reader pastes the token into. The
file now runs the same way in Visual Studio 2022, Rider / IntelliJ, and VS Code.
Replace the sample's hand-written Vault and Azure custodian code with the
Abblix.Oidc.Server.Vault and .Azure packages: one AddVaultExternalKeys or
AddAzureExternalKeys call each, selected by configuration, with no custodian code
in the sample. The project references the in-development packages locally until
2.4 publishes, when these become package references.
…ySample README

Match the consolidated IKeyCustodian: the backend does sign and unwrap by
version plus version enumeration by key name, not a single public-key fetch.
The external-key packages split their registration into two: which
custodian holds the keys, and how the library uses them. The sample now
shows that split, which suits it well: the KeyCustodian setting picks the
custodian, and the tier call after it is the same line for either one.
Each enum member's name doubles as its configuration section, so the
section name is no longer repeated at the call site.

The key names move from the Vault and Azure sections to Provider, where
they belong: they are not part of a connection, and the same names work
whichever custodian holds them. The encryption key is now named only when
the sample issues encrypted tokens, since an unnamed one publishes
nothing rather than being guessed.

The README loses a claim that was wrong: a key name is not the published
kid. Transit publishes oidc-sign:1 and Key Vault oidc-sign/<version>,
which is what lets a rotation overlap.
The comment read "only needed while EncryptAccessToken is on", which is
true here and wrong in general: an encryption key also decrypts inbound
JWE a client sent, and that has nothing to do with encrypting our own
tokens. The condition itself is right for this sample, since
client_credentials sends no request object or client assertion, so the
comment now says that rather than stating a rule that does not hold.
Extends ExternalKeySample to demonstrate both key-handling tiers: a UseKeysIn
setting picks between keys held in the custodian and keys minted in-process and
sealed to it, wired for both Vault (KV ring) and Azure (Blob ring). The compose
file provisions the key-encryption key, the README shows the opposite kill-switch
behaviour, and the encryption algorithm now comes from configuration. Renames the
tier calls to UseKeysInCustodian / UseKeysInProcess, dispatches the tier with an
exhaustive switch, and adds the project to GettingStarted.slnx.
Two breakages, one of them long-standing.

The project references still pointed at the pre-restructure layout, so the
sample stopped building when the library moved its projects under src/. That
went unnoticed because nothing builds this sample in CI.

The key ring has since moved from Abblix.Oidc.Server into Abblix.Jwt, where it
belongs: it mints, seals and rotates JsonWebKeys and knows nothing about being
an OpenID Provider. CustodianHeldKeys and MintedKeys came with it, so the
sample needs the new namespace alongside the one it already had - the tier
calls themselves are unchanged, and so is every line of the sample's own code.

Builds clean.
The backend packages are Abblix.Jwt.Vault and Abblix.Jwt.Azure now: they hold
keys and know nothing about being an OpenID Provider, so they no longer carry
the server's name or reference it.

The choice they open is where a key lives rather than a tier - the word implied
a ranking these two options do not have. Renamed in the sample's own code and
prose to match, so a reader moving between the sample and the library meets one
vocabulary.

Nothing about what the sample does changed: the same two calls, in the same
order, with the same settings.

Builds clean.
The sample called AddOidcMinimalApi with an options delegate, and that
overload is gone: AddOidcMinimalApi is now the transport alone, while
AddOidcServices is core plus transport and takes the options. The MVC
package spells the latter identically, which is the point of the name -
swapping adapters changes the package reference and the endpoint mapping,
not this line.

The sample had not built since that change. Verified both ways this time:
against the referenced tree, and against a copy repointed at the branch that
moves the key placement calls into Abblix.Jwt, since the sample is what says
whether that move reaches a consumer.

It does, without an edit: the placement calls resolve from
Abblix.Jwt.ExternalKeys, which the sample already imports for
CustodianHeldKeys and MintedKeys.

Also dropped the word tier, which the library no longer uses for the key
placement.
The step downloaded a release archive and unpacked it straight into a
binary it then ran with the job's permissions, so whatever the network
answered was executed. The sum comes from the release's own
actionlint_<version>_checksums.txt, and --strict fails the job on a
malformed line rather than passing a check that verified nothing.

The sum lives beside the version it belongs to, so raising one without
the other stops the job instead of silently trusting the download.
…y-sample

# Conflicts:
#	.github/workflows/lint-workflows.yaml
The sample was held against the sibling library checkout and a private feed
because the packages carrying the external-key seam had not shipped. They have,
so it now references Abblix.Oidc.Server.MinimalApi, Abblix.Jwt.Vault and
Abblix.Jwt.Azure at 2.4.0, and the NuGet.config that reached for the private feed
is gone. A reader cloning this repository alone can now build it, which the
project references made impossible.

Restored as a reader would have it, from nuget.org into an empty package folder:
eight Abblix packages, all 2.4.0, and the build carries no warnings.

Two README claims did not survive being run. The signing header carries
"oidc-sign:1" and the encryption header "oidc-enc:1", not the bare key names -
the same passage further down already said a bare key name is never a kid, so the
two examples contradicted it. Both now print what the server prints, and the
encryption example names its "enc" as well.
Four statements in the sample described what it was meant to guarantee rather
than what it did, and the fifth left a security-relevant default to chance.

The Azure section carried empty TenantId, ClientId and ClientSecret fields while
the README said credentials never come from configuration. The package really
reads all three, so the file offered a labelled place to put a service-principal
secret in a public repository. The fields are gone and the README now says the
package supports both paths while the sample configures only the credential
chain.

The client secret moves to the same shape both sibling providers use: the
configuration carries its SHA-512 hash, so no provider here keeps a recoverable
secret in a file. Driven both ways - the demo secret still issues a token, a
wrong one gets 401 invalid_client.

The posture settings are read as nullable and refused when absent. Bound straight
to the enum, a missing one took the first member, so deleting the setting picked a
security posture by declaration order while the comment beside it promised a
startup failure. It fails now, which is what the comment always said.

The settings table claimed the Vault token is taken from an environment variable.
That spelling is just the section path; the row now says so, and points at the
AppRole and Kubernetes sections a deployment uses instead.

A launch profile joins the one every other sample here ships, pinning the URL the
README uses and naming the environment, because the environment decides what the
sealed-custodian failure returns: a bare 500, or the exception and its stack trace
naming the custodian key path. The README now says which is which. The Vault token
is deliberately not in that file.
No member of the provider's options carries a default any more. A default in code
for the client secret's hash is credential material in code, which is the thing
this sample exists to argue against, and the rest were a second copy of values
appsettings.json already holds - two sources for one value, drifting silently.

`required` alone does not achieve that: the configuration binder ignores it and
leaves an absent setting null, and the null then surfaces deep inside whatever
first reads it, naming a framework parameter. Driven with the hash removed, the
failure was `ArgumentNullException ... (Parameter 's')` from the base64 decoder.
So the settings are checked explicitly after binding, and a mistyped hash is
decoded through a named path. Both refusals were driven: an absent hash and one
holding the secret itself each name `Provider:ClientSecretSha512Hash` and say
what belongs there. The README gains the command that computes the hash, which
was run and reproduces the value in the file.

Also from the round: the sentence introducing the hash promised the recoverable
secret is in no file the provider ships, and printed it in the next clause; both
it and the README row now claim only what is checkable, that the configuration
the server loads carries the hash rather than the secret. The compose comment
naming all three provisioned keys had landed while the README command beside it
still named two, and the Azure paragraph offered a credential path without ever
spelling it - the three settings are named now, with no slot for them in any
committed file, and the paragraph says the signed-in identity needs Key Vault
Crypto User, which stopped being optional once configuration credentials were no
longer an alternative.
The list checked up front demanded three names the posture in use never reads:
the encryption key only matters with encryption on, the key-encryption key only
where the server mints its own keys, and the signing key only where it does not.
So the shipped configuration could be refused over a value nothing would have
touched, while three sentences elsewhere told the reader those settings are used
only in those cases.

Each value now passes through one Required call at the point it is consumed, so a
setting that stops being read cannot leave a check behind demanding it. Five
configurations were driven: with the shipped posture both conditional names may be
absent and the server starts; in the minting posture and with encryption on each
is demanded by name; the signing key name is demanded where it is read.

The comment above it claimed every member is required and none carries a default.
Two are optional and say so by not being marked: the encryption toggle, and the
algorithm that decides whether a minted encryption key exists at all. Both the
comment and the type now name them.

The hash command in the README used a base64 flag that exists only in the GNU
tool, so it fails on macOS. It goes through openssl instead, which is already in
the pipe; driven, and the output is the same single line.
The type summary said the members without `required` are the optional ones, and
the comment above the binding said the same. Both are wrong in the same way: the
keyword constrains object initializers and the binder ignores it, so it says
nothing about which settings a configuration must carry. Two marked members may be
absent in the posture the sample ships with, which the driven configurations
already showed, and the comment contradicted itself four lines further down.

Both texts now state the rule and leave the specifics to each member, where a
setting read in one posture only says so - true now of every such member, the
signing key name included, which had said nothing about the posture that reads it.

The counts are gone with them. A sentence that says how many members are optional
ages silently the moment the set changes, and nothing in a build reads it.
@kirill-abblix
kirill-abblix merged commit 17230cc into master Sep 6, 2026
2 checks passed
@kirill-abblix
kirill-abblix deleted the feature/external-key-sample branch September 6, 2026 14:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant