Skip to content

Create-on-404 for file-format stores (HCVKVPFX/JKS/P12) + net10 target + tests#40

Open
spbsoluble wants to merge 7 commits into
release-3.2from
fix/create-on-404-file-stores
Open

Create-on-404 for file-format stores (HCVKVPFX/JKS/P12) + net10 target + tests#40
spbsoluble wants to merge 7 commits into
release-3.2from
fix/create-on-404-file-stores

Conversation

@spbsoluble

Copy link
Copy Markdown
Contributor

Summary

Makes the file-format Vault stores (HCVKVPFX, HCVKVJKS, HCVKVP12) self-healing: a Management-Add against a store that was never successfully Created now auto-seeds the empty store + passphrase on first use and proceeds with the Add, instead of failing with a 404. Also adds net10.0 to TargetFrameworks (UO 25.5.x runtime), and a new xUnit test project covering CreateFileStore.

Why

Command issues exactly one Management/Create job per store at registration time. If that one Create failed — plugin bug, transient Vault error, deferred policy change, anything — Command never retries Create. Every subsequent Management-Add then hit GetCertificateAndPassphrase, got 404s for the missing cert blob and passphrase, and threw. The store was permanently broken without operator intervention (re-register, hand-seed Vault, or PUT CreateIfMissing to trigger a new Create).

The fix treats "Add against a store that doesn't exist yet" as a valid state and seeds it inline, matching how HCVKVPEM already behaves (CreatePemStore writes an empty PEM secret as part of registration).

What's in this PR

  1. build: add net10.0 to TargetFrameworks — UO 25.5.x runs .NET 10. A net8.0 plugin throws System.NotSupportedException at HcvKeyfactorClient.GetTokenPoliciesAsync() because the substituted System.Net.Requests v10 assembly rejects the v8 API the plugin compiles against. net6.0 and net8.0 are retained.

  2. fix: create-on-404 for file-format stores — five interlocking changes:

    • PutCertificateIntoFileStore auto-seeds (random passphrase + empty PFX/JKS/P12 file) when both the cert blob and the passphrase are missing at the configured Store Path. Orphaned stores (passphrase missing but cert present) raise a clear error rather than silently overwriting.
    • GetCertificateAndPassphrase: passphrase read tolerates 404 the same way the cert read already did — both empty is the create-if-not-exist signal. Catch pattern changed from catch (VaultApiException) when (ex.StatusCode == 404) to a plain catch with explicit StatusCode test: the when filter does not fire reliably for exceptions raised inside the async state machine on .NET 10 + VaultSharp 1.17 (observed end-to-end on a live UO 25.5.x lab). Applied to the cert-read catch too for symmetry.
    • PatchSecretAutoAsync (KV v2): catches 404 and falls back to WriteSecretAutoAsync. KV v2 Patch returns 404 when the secret doesn't exist; falling back to Write makes the operation idempotent (create-or-merge).
    • CreateFileStore fixes that surfaced once the auto-seed code started exercising it: cert/passphrase writes were targeting the parent KV path instead of the leaf; passphrase write unconditionally used Patch (404s on first write). Now uses Write for non-JSON layouts and Patch for JSON layouts. Same Patch-only bug fixed for the cert blob write in PutCertificateIntoFileStore.
    • _storeTypeprotected, plus virtual on GetKVVersionAsync / ReadSecretAutoAsync / WriteSecretAutoAsync / PatchSecretAutoAsync so the new test project can stub Vault I/O.
  3. test: xUnit project for CreateFileStore behaviour — new hashicorp-vault-orchestrator.Tests project. 7 tests covering the leaf-path writes, JSON vs non-JSON layouts, Patch→Write 404 fallback, passphrase generation, and the unrecognised-store-type error. InternalsVisibleTo added via AssemblyInfo.cs.

The PR also picks up three pre-existing fixes for the same .NET 10 / cert-store stability effort that hadn't been pushed: prevent process crash when InitProps throws on misconfigured store, always normalize StorePath trailing slash for PEM/PKI store types, handle missing cert gracefully in GetCertificateAndPassphrase.

Test plan

  • dotnet build -c Release — clean (0 errors, 29 warnings, all pre-existing)
  • dotnet test — 7/7 pass on net8.0
  • Live lab validation against Keyfactor Command 25.5.x + UO 25.5.x + OpenBao:
    • HCVKVPFX, HCVKVJKS, HCVKVP12 stores that had no successful Create — first Management-Add succeeded on every store, auto-seed traces fired exactly once per store
    • 15/15 file-format Management-Add jobs returned Success (5 algorithms × 3 formats: RSA-2048/4096, ECDSA P-256/P-384/P-521)
    • HCVKVPEM continues to work unchanged
    • Subsequent Add calls hit the warm path (existing store + passphrase found), no spurious re-seeding
  • Reviewer to validate on a kept-up-to-date Vault Enterprise install if available (namespace-scoped mounts not exercised in our lab)

InitProps was declared as `async void`, so exceptions propagated via
Task.ThrowAsync as unhandled exceptions and crashed the .NET process.
Changed to `async Task` and updated all three Initialize overloads to
block synchronously via .GetAwaiter().GetResult(), so errors surface
as job failures rather than process restarts.

Also wraps GetTokenPoliciesAsync in try-catch (best-effort diagnostic
logging; token may lack lookup-self capability).
The trailing slash required by HcvKeyValueClient was only appended when
StorePath came from the store's custom Properties dict. When it came from
config.CertificateStoreDetails.StorePath (the normal case for stores
defined without an inline StorePath property), the slash was skipped and
path concatenation produced /certspath instead of /certs/path.

Move the normalize+trailing-slash block outside the ContainsKey guard so
it applies regardless of where the value originated.
For Management-Add on a fresh file store (no existing keystore in Vault),
the cert read returns 404. The old code threw DirectoryNotFoundException
before ever reading the passphrase, failing the job.

Split cert and passphrase reads into separate try-catch blocks. A 404 on
the cert path is now treated as an empty store — certContent stays empty
so AddCertificate creates a new keystore. Passphrase read continues and
is still required.

Also guards against null values in both fields (null value in a KV secret
no longer NPEs on .ToString()).
UO 25.5.x ships with the .NET 10 runtime. Loading a plugin compiled
against net8.0 produces System.NotSupportedException at
HcvKeyfactorClient.GetTokenPoliciesAsync() when System.Net.Requests
v10.0.0.0 is substituted for the v8.0.0.0 the plugin was built against.

Adding net10.0 to TargetFrameworks lets the plugin be built against the
same runtime UO 25.5.x supplies. net6.0 and net8.0 are retained for
backward compatibility with older UO releases.
Make Management-Add against an empty or never-seeded file-format store
succeed by auto-creating the store on first use, rather than failing
with 404s for a missing cert blob and passphrase.

Previous behaviour: Command issues exactly one Management/Create job
per store at registration time. If that Create failed for any reason
(plugin bug, transient Vault error, etc.), Command never retries Create
— so every subsequent Management-Add hit GetCertificateAndPassphrase,
got back a 404 for the (non-existent) cert and passphrase secrets, and
threw. The store was permanently broken without operator intervention.

Five related fixes, all on the same code path:

1. PutCertificateIntoFileStore: when GetCertificateAndPassphrase
   returns empty cert AND empty passphrase, call CreateFileStore() to
   seed an empty store + random passphrase, then re-read and proceed.
   Mirrors how HCVKVPEM's CreateCertStore writes an empty PEM secret
   up-front. When only the passphrase is missing (orphaned store) we
   refuse with a clear error rather than silently overwriting.

2. GetCertificateAndPassphrase: passphrase read is now tolerant of a
   404 in the same way the cert read already was — a missing passphrase
   on a fresh store is a signal to create-if-not-exist, not a fatal
   error. Catch pattern switched from `catch (VaultApiException) when
   (ex.StatusCode == 404)` to a plain catch with explicit StatusCode
   test inside: the `when` filter has been observed not to fire
   reliably for exceptions raised from inside the async state machine
   on .NET 10 + VaultSharp 1.17, sending the flow into the generic
   `catch (Exception)` branch even for 404s. The same pattern is
   applied to the cert-read catch for symmetry.

3. PatchSecretAutoAsync (KV v2): catches a 404 from PatchSecretAsync
   and falls back to WriteSecretAutoAsync. KV v2 Patch returns 404 when
   the secret doesn't yet exist; falling back to Write makes the call
   idempotent ("create-or-merge") rather than requiring callers to
   pre-create every path. Same robust catch pattern.

4. CreateFileStore: pre-existing bugs that surfaced once the path
   started getting exercised by the auto-seed code:
   - Cert/passphrase writes were targeting the PARENT path instead of
     the full secret path. KV v2 only accepts writes at the leaf, so
     these previously failed.
   - The passphrase write unconditionally used PatchSecretAutoAsync,
     which 404s on first write. Now uses Write for non-JSON layouts
     (whole-secret-is-passphrase) and Patch for JSON layouts (shared
     secret with a passphrase property to merge).
   - PutCertificateIntoFileStore had the same Patch-only bug for the
     cert blob write — fixed the same way.

5. Expose hooks for unit testing without a real Vault: _storeType is
   now `protected` and GetKVVersionAsync / ReadSecretAutoAsync /
   WriteSecretAutoAsync / PatchSecretAutoAsync are now `virtual`, so
   the new TestableHcvKeyValueClient subclass can stub Vault I/O.

Validated end-to-end against a Keyfactor Command 25.5.x + UO 25.5.x +
OpenBao lab: a Management-Add against an HCVKVPFX, HCVKVJKS, and
HCVKVP12 store that had never had a successful Create now succeeds on
the first try (auto-creates empty store + passphrase, then adds the
new cert). 15/15 file-format Management jobs returned Success across
five algorithms (RSA-2048/4096, ECDSA P-256/384/521) × three formats.
Add a hashicorp-vault-orchestrator.Tests xUnit project covering the
file-format CreateFileStore path that the previous commit fixed.

The TestableHcvKeyValueClient subclass stubs all Vault I/O so path-
level behaviour can be asserted without a real Vault connection:
ReadSecretAutoAsync returns canned responses; WriteSecretAutoAsync
and PatchSecretAutoAsync record their calls for assertion.

7 tests cover:
  - cert + passphrase written at the expected leaf paths
  - non-JSON layout writes the whole secret (no JSON property
    indirection)
  - JSON layout writes the value under the configured property name
  - 404 from Patch falls back to Write (create-or-merge)
  - random passphrase actually gets recorded in the Write call
  - unrecognized store-type values throw InvalidOperationException

InternalsVisibleTo("hashicorp-vault-orchestrator.Tests") added via
hashicorp-vault-orchestrator/AssemblyInfo.cs so the tests can reach
the protected/internal members of HcvKeyValueClient.

.gitignore extended to exclude the Tests project's bin/obj plus
.idea (Rider workspace files).

  $ dotnet test
  Passed!  - Failed: 0, Passed: 7, Skipped: 0, Total: 7
@spbsoluble spbsoluble force-pushed the fix/create-on-404-file-stores branch from 303d4c2 to 526a9af Compare May 11, 2026 16:46
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