Engineering

Dual-Control Policy Publishing with HashiCorp Vault Transit

A signed policy bundle is the only artifact a governance proxy should trust. This is how we publish them under dual control, rotate keys without downtime, and prove integrity at every request — using HashiCorp Vault Transit and Ed25519 signatures.

By Krasper Engineering 27 May 2026 9 min read
Dual-Control Policy Publishing — hero

TL;DR. If a single engineer can push a policy change that loosens redaction, allows a new endpoint, or whitelists a model, the governance proxy is theatre. Real enforcement starts with signed bundles, dual control on every publish, and key rotation that does not require a redeploy. HashiCorp Vault Transit handles the cryptography; Ed25519 keeps verification cheap; a small set of policies around publishing closes the loop.

A governance proxy decides — per request — whether a prompt may leave your perimeter, whether PII gets redacted, whether the response may include code, which models are even reachable. That decision boundary is enforced by a policy bundle: a versioned, machine-readable artifact compiled from declarative rules. Whoever can change that bundle controls the perimeter.

This post walks through the model we use to publish policy bundles in Raigate: how Ed25519 signatures are issued through HashiCorp Vault Transit, how dual control is enforced at the publishing step (not at the IDE), how the proxy verifies bundles on load, and how we rotate signing keys without a maintenance window.

Contents

  1. Why dual control belongs at the policy plane
  2. Anatomy of a signed policy bundle
  3. Vault Transit as the signing oracle
  4. The publish workflow, end to end
  5. Verification at the proxy layer
  6. Key rotation and the resigner pattern
  7. Compliance angle — what auditors actually ask for

1. Why dual control belongs at the policy plane

Most teams already enforce dual control somewhere — production deploys, database migrations, IAM changes. Policy bundles for an AI governance proxy deserve the same treatment, for a simple reason: the bundle is the policy.

A one-line change to a JSON predicate can:

  • silently disable PII redaction for a tenant,
  • allow a new outbound endpoint that exfiltrates prompts,
  • whitelist an unapproved model with weaker safety alignment,
  • turn fail-closed defaults into fail-open.

Code review alone is not enough. A reviewer who approves the PR is not the same as a cryptographic co-signer who attests to the artifact that gets deployed. The two can drift — through a rebase, a merge conflict, a last-minute "tiny" amend after approval, or a compromised CI runner.

The fix is to move the trust boundary off the source repo and onto the signed artifact. Every running proxy verifies a signature before loading a bundle. No signature, no load. Wrong signature, no load. Right signature from the wrong key, no load.

2. Anatomy of a signed policy bundle

A bundle is a deterministic archive. Inputs go in, a canonical byte stream comes out, that byte stream gets hashed and signed. Determinism matters because the same logical policy must produce the same hash on any machine — otherwise signature verification becomes a flake instead of a guarantee.

text
bundle/
├── manifest.json          # version, created_at, source commit, framework refs
├── policies/
│   ├── redaction.json     # PII handling rules, fail-closed defaults
│   ├── routing.json       # which models, which endpoints, per tenant
│   └── content.json       # blocked categories, output constraints
├── checksums.txt          # SHA-256 of every file above, sorted by path
└── signatures/
    ├── primary.sig        # Ed25519 over checksums.txt
    └── secondary.sig      # Ed25519 over checksums.txt, different signer
Deterministic bundle layout
Inputs
policies/
  • redaction.json — PII rules, fail-closed defaults
  • routing.json — models + endpoints per tenant
  • content.json — blocked categories, output limits
Canonicalization
checksums.txt
SHA-256 of every file, sorted by path. The bytes the signers actually sign.
Dual control
signatures/
  • primary.sig — Ed25519, policy lead identity
  • secondary.sig — Ed25519, security review identity
  • Both required. Different humans, different Vault keys.
Deterministic archive
Policy Bundle
Versioned, machine-readable artifact loaded by every proxy.

checksums.txt is the canonicalization point. Files are listed alphabetically; each line is <sha256> <path>. The signers never sign the raw archive — they sign checksums.txt. This keeps signatures stable across archive formats and tooling versions, and it gives the verifier a fast path: hash the files, regenerate checksums.txt, compare to what was signed.

Two signatures, two different keys, two different humans. That is the dual-control surface.

3. Vault Transit as the signing oracle

We never hold raw private keys on developer machines or CI runners. Vault Transit exposes signing as an API; the key material stays inside Vault and can be rotated, versioned, or revoked without touching any client.

Two named keys live in the Transit backend:

  • policy-bundle-primary — held by the policy engineering lead role
  • policy-bundle-secondary — held by the security review role

Both are Ed25519. Both are non-exportable. Both have versioning enabled.

Issuing a signature is a single API call against Vault — the input is the base64-encoded SHA-256 of checksums.txt, the output is a base64 signature plus the key version that produced it:

bash
vault write transit/sign/policy-bundle-primary/sha2-256 \
    input="$(sha256sum checksums.txt | awk '{print $1}' | xxd -r -p | base64)" \
    prehashed=true \
    signature_algorithm=pkcs1v15
Vault Transit sign call (primary key)

The signing call is gated by a Vault policy that requires both an authenticated human identity and an active approval ticket. No service account can call transit/sign/policy-bundle-primary directly. The same is true, with a different policy and a different identity group, for the secondary key.

This is where dual control becomes mechanical instead of cultural: a single person cannot produce both signatures, because no single identity has the Vault entitlements to call both signing endpoints.

4. The publish workflow, end to end

The signing oracle is the centrepiece, but the workflow around it is what makes the system safe to operate. The path a policy change takes from draft to deployed:

AUTHOR REVIEWER VAULT TRANSIT DISTRIBUTOR PROXY FLEET open draft PR build candidate bundle request review + sign dry-eval on corpus sign(primary) Ed25519/v3 sign(secondary) Ed25519/v2 publish doubly-signed bundle distribute by content-hash verify both signatures Ed25519 × 2 atomic policy swap fail-closed if verify fails

Concretely:

  1. Author opens a policy change as a normal PR. CI builds a candidate bundle and posts its SHA-256 as a status check. The PR cannot be merged without that hash matching the latest commit on the branch.
  2. Reviewer opens the candidate bundle in the policy studio, runs a dry evaluation against a regression corpus (replayed historical traffic plus a curated set of red-team prompts), and either rejects or marks the bundle ready for publish. The reviewer is not the author.
  3. Primary signature is requested. The system pulls the candidate bundle, recomputes checksums.txt, calls Vault Transit with the author's identity asserting the primary signing policy. The signature is attached.
  4. Secondary signature is requested under the reviewer's identity, against the secondary key. If either signature is missing or invalid, the bundle never leaves the build environment.
  5. Distributor publishes the doubly-signed bundle to the artifact store under a content-addressable path: bundles/<sha256>.tar.gz. The fleet pulls from there.
  6. Proxy fleet verifies both signatures against the pinned public keys for the current key versions, then atomically swaps the active policy. A load failure leaves the previous bundle in place — fail-closed all the way down.

The author cannot self-approve. The reviewer cannot publish without the author's primary signature. The distributor only accepts bundles whose signatures both verify under the current public-key set. Each gate is a hard cryptographic check, not a UI affordance.

5. Verification at the proxy layer

The proxy holds a small, version-pinned set of public keys in its configuration:

json
{
  "trusted_signers": [
    {
      "name": "policy-bundle-primary",
      "key_versions": {
        "v2": "MCowBQYDK2VwAyEA<...base64 ed25519 public key...>",
        "v3": "MCowBQYDK2VwAyEA<...base64 ed25519 public key...>"
      }
    },
    {
      "name": "policy-bundle-secondary",
      "key_versions": {
        "v1": "MCowBQYDK2VwAyEA<...base64 ed25519 public key...>",
        "v2": "MCowBQYDK2VwAyEA<...base64 ed25519 public key...>"
      }
    }
  ],
  "require_both": true,
  "min_key_versions": { "primary": 2, "secondary": 1 }
}
trusted_signers configuration on the proxy

The verification routine, in plain steps:

  1. Extract the archive into a scratch directory.
  2. Regenerate checksums.txt from the extracted files in sorted order.
  3. Hash it with SHA-256.
  4. For each .sig file, read the embedded key name and version, look it up in trusted_signers, fail closed if missing.
  5. Verify the Ed25519 signature against the recomputed hash.
  6. If require_both is set, both must verify. Otherwise reject.

Ed25519 was chosen for verification cost. A single signature verify is sub-millisecond on commodity hardware — cheap enough to do on every bundle reload, cheap enough to do during the proxy's health check, cheap enough to include in a startup probe that refuses to mark the pod ready until the active bundle re-verifies.

The proxy never trusts a key it has not been told about. New key versions require a configuration update — which itself goes through the same review-and-deploy path as any production change.

6. Key rotation and the resigner pattern

Ed25519 keys do not weaken with time, but the operational principle still holds: any key that has signed long enough should be rotated. The risk is not the algorithm — it is custody, turnover, and the slow accretion of identities that could have touched a signing endpoint over a multi-year window.

The painful part of rotation is not generating a new key. Vault Transit does that in one call:

bash
vault write -f transit/keys/policy-bundle-primary/rotate
Rotate the primary signing key

The painful part is what happens to already-deployed bundles signed with the old key version. Two choices:

  1. Force a re-publish of every bundle, every tenant, on every rotation. Loud, disruptive, and tempting to skip the next time.
  2. Resign existing bundles in place using a controlled batch process.

We use the second. The resigner is a short-lived job that runs against the artifact store: it loads each currently-deployed bundle, re-verifies the existing signatures (the old key versions are still trusted during the overlap window), then requests a new primary signature under the rotated key version and writes the bundle back with both signatures attached.

Step 01
T+0h
Rotate primary key
  • Vault Transit produces a new key version (v_new)
  • Old version (v_old) remains valid for verification
  • Proxy trust config updated to accept both versions

StateTwo key versions in circulation; nothing has changed yet for deployed bundles

Step 02
T+0–48h
Resign deployed bundles
  • Resigner job lists every bundle in the artifact store
  • For each: verify(v_old) + verify(secondary), then sign(v_new)
  • Bundle rewritten with new primary signature, secondary preserved
  • Resigner identity scoped to only this workflow

StateEvery deployed bundle now carries a v_new primary signature

Step 03
T+72h
Retire v_old
  • Remove v_old from proxy trust config
  • Remove v_old from Vault Transit
  • Audit log preserves both signing events for the historical bundle

Statev_old no longer trusted anywhere; rotation complete with zero downtime

Two things to note:

  • The secondary signature is preserved unchanged. Rotating both keys simultaneously would erase the dual-control evidence on every historical bundle. We rotate them on staggered schedules.
  • The resigner runs with its own dedicated identity that is only allowed to call the resign workflow — it cannot author new bundles, cannot publish novel policy changes, cannot remove signatures. It can rebind an already-signed-by-two-humans bundle to the latest primary key version. That bound scope is the entire reason it is safe to automate.

7. Compliance angle — what auditors actually ask for

EU AI Act Article 12 (record-keeping), ISO 27001 Annex A.12.4 (logging), SOC 2 CC7.2 (change management) — they all converge on the same three questions when applied to AI governance policy:

  1. Who authorized this change, and how do you prove it?
  2. Can the change be tampered with after authorization?
  3. Can you reconstruct what policy was active at the moment of any past decision?

Signed bundles answer all three:

  • Who — two distinct Vault identities, each with their own audit-logged signing event in Vault's audit device. The identities are bound to humans via your IdP. The signing event records the timestamp, the key version, and the input hash.
  • Tamper-resistance — any modification to any file inside the bundle changes checksums.txt, which invalidates both signatures. The proxy refuses to load it.
  • Historical reconstruction — every request the proxy serves can carry a policy_bundle_sha256 field in the audit log. Cross-reference that hash against the artifact store and you have the exact bundle, with its exact signatures, that decided the request.

The audit chain runs end-to-end: the request log points to the bundle, the bundle points to the signing events, the signing events point to the human identities. No verbal assurances required.

Closing

Dual control on policy publishing is one of those engineering investments that looks like overhead until the first time it stops a bad change from shipping — at which point the entire cost is paid back in a single afternoon. The cryptography is well-understood, the tooling exists, and Ed25519 verification is cheap enough that there is no performance argument against doing it on every bundle load.

The harder work is operational: defining the two signing roles, putting the right Vault policies in front of the keys, building a resigner you trust to run unattended, and resisting the temptation to add a "break glass" path that defeats the whole model.

We will publish the EU AI Act compliance checklist next — the same control surface, mapped article by article, with explicit pointers to where each artifact (signed bundle, audit log, key-rotation record) satisfies which requirement.

Further reading

  • HashiCorp Vault — Transit Secrets Engine documentation
  • RFC 8032 — Edwards-Curve Digital Signature Algorithm (EdDSA)
  • ISO/IEC 27001:2022 — Annex A.12.4 (Logging and Monitoring)
  • Regulation (EU) 2024/1689 — Articles 12, 15, 26
Back to Blog

Ready to secure your
enterprise infrastructure?

Schedule a technical briefing. No sales pitch — just architects and your team.