Holding the bootstrap key in Azure Key Vault Managed HSM¶
A 30–45 minute walkthrough for the operator who wants to move Yutha's bootstrap signing key out of process memory and into an Azure Managed HSM. By the end you'll have:
- An activated Managed HSM with an Ed25519 key (
kty=OKP-HSM,crv=Ed25519). - A least-privilege RBAC binding (Managed HSM Crypto User) on exactly that key.
- A
DefaultAzureCredential-based auth path (managed identity for AKS / Container Apps; env-var SP for VMs;az loginfor local dev). - An
AzureKvSignerwired through the control plane, with the in-process signer no longer holding the bootstrap seed.
What you get for free. Every signing call site in the substrate
already flows through the Signer trait
since RFC 0015's async refactor; swapping InProcessSigner for
AzureKvSigner is a single construction-site change. No call paths
move. No receipt formats change.
What you write. The Managed HSM, the key, the RBAC binding, the auth path, and the env-var wiring.
The hard prerequisite¶
You need a Managed HSM, not a standard Key Vault. The standard
Azure Key Vault tier (*.vault.azure.net) does NOT support Ed25519
keys. Trying to point this signer at one will fail at startup with
SignerError::UnsupportedAlgorithm.
Managed HSM is a separate Azure resource SKU (~$3/hour per HSM instance + per-operation fees as of 2026). If your platform team already runs one, skip §1. If not, you'll need a privileged role to create one (Owner or Contributor on the resource group); this isn't something the Yutha control plane's service principal should be doing ad-hoc.
If you haven't operated a Yutha swarm before, read the operator quickstart first.
If you're on AWS, see the Vault Signer walkthrough; if you're on GCP, see GCP KMS Signer.
This walkthrough implements the Azure backend pinned by RFC 0017 §4.3.
Prerequisites¶
- Azure subscription with the Managed HSM SKU available (most public regions).
azCLI v2.50+ authenticated (az login) and pointed at the right subscription (az account set --subscription <id>).- A resource group where you'll create the HSM.
- About 50–150 ms of additional signing latency budget per envelope — Managed HSM signing is slower than software KMS by design.
1. Create + activate the Managed HSM¶
Skip this section if your platform team has already provisioned a Managed HSM you can use. Go straight to §2.
Managed HSM creation is a two-step process: provision (Azure brings the hardware online) and activate (you download the security domain — three RSA key shares that encrypt the HSM's root key).
HSM_NAME=yutha-hsm # globally unique
RG=yutha-rg
LOCATION=eastus
# Designate three administrators (by object ID).
# Use `az ad signed-in-user show --query id -o tsv` for your own ID.
ADMIN1_OID=...
ADMIN2_OID=...
ADMIN3_OID=...
az keyvault create \
--hsm-name $HSM_NAME \
--resource-group $RG \
--location $LOCATION \
--administrators $ADMIN1_OID $ADMIN2_OID $ADMIN3_OID \
--retention-days 28
After ~10 minutes the HSM is provisioned but not yet usable — every
data-plane operation (including key creation) returns
HsmNotActivated. To activate, generate 3 RSA key pairs and download
the security domain:
mkdir certs
for i in 0 1 2; do
openssl req -newkey rsa:4096 -nodes \
-keyout certs/cert_$i.key \
-x509 -days 365 \
-subj "/CN=yutha-hsm-admin-$i" \
-out certs/cert_$i.cer
done
az keyvault security-domain download \
--hsm-name $HSM_NAME \
--sd-wrapping-keys certs/cert_0.cer certs/cert_1.cer certs/cert_2.cer \
--sd-quorum 2 \
--security-domain-file ${HSM_NAME}-SD.json
Store the
.keyfiles + the-SD.jsonfile like you'd store nuclear codes. Losing them means losing the ability to recover the HSM if anything goes wrong with the Azure-side keys. Production deployments typically keep these in three different physical safes with separation of access; for testing, an encrypted USB drive stored offline is the minimum-viable hygiene.
2. Create the Ed25519 key¶
OKP-HSM is the key type and Ed25519 is the curve. Yutha uses the
key for sign + verify operations.
KEY_NAME=bootstrap
az keyvault key create \
--hsm-name $HSM_NAME \
--name $KEY_NAME \
--kty OKP-HSM \
--curve Ed25519 \
--ops sign verify \
--query "key.kid" -o tsv
The output is the full key ID, e.g.
https://yutha-hsm.managedhsm.azure.net/keys/bootstrap/a1b2c3d4....
The hex tail is the key version — save it; you'll pin it in §5.
To confirm the algorithm matches what Yutha expects:
Should print {"kty": "OKP-HSM", "crv": "Ed25519"}. If kty is
anything else, the Yutha signer will reject the key at startup.
3. Create the Yutha service principal (or reuse one)¶
This is the identity the control-plane process authenticates as. Skip ahead if you already have one.
SP_NAME=yutha-control-plane
# Create an SP, capture appId + objectId.
SP_JSON=$(az ad sp create-for-rbac --name $SP_NAME --skip-assignment)
APP_ID=$(echo $SP_JSON | jq -r .appId)
TENANT_ID=$(echo $SP_JSON | jq -r .tenant)
CLIENT_SECRET=$(echo $SP_JSON | jq -r .password)
SP_OID=$(az ad sp show --id $APP_ID --query id -o tsv)
echo "AZURE_CLIENT_ID=$APP_ID"
echo "AZURE_TENANT_ID=$TENANT_ID"
echo "AZURE_CLIENT_SECRET=$CLIENT_SECRET # store as a secret"
echo "SP_OID=$SP_OID # used in the next step"
Production note. SP client secrets are long-lived; prefer federated credentials (Workload Identity / OIDC) or managed identity where the runtime supports it. The env-var SP path is the lowest common denominator.
4. Grant least-privilege RBAC¶
The role is Managed HSM Crypto User (predefined; covers
sign, verify, get, list, update, import on keys). Scope it
to the specific key, not the whole HSM.
az role assignment create \
--hsm-name $HSM_NAME \
--assignee-object-id $SP_OID \
--assignee-principal-type ServicePrincipal \
--role "Managed HSM Crypto User" \
--scope "/keys/$KEY_NAME"
If you scoped at / instead of /keys/$KEY_NAME, the SP would have
crypto-user rights on every key in the HSM — that's only appropriate
for an HSM dedicated to Yutha.
5. Pick an auth path¶
AzureKvSigner uses DefaultAzureCredential. Three paths, pick
whichever fits your deployment:
Option A — Local dev (your laptop)¶
DefaultAzureCredential will pick up the cached az token and use
your identity. Make sure your user account has the
Managed HSM Crypto User role on the key, or impersonate the SP:
az login --service-principal \
--username $AZURE_CLIENT_ID \
--password $AZURE_CLIENT_SECRET \
--tenant $AZURE_TENANT_ID
Option B — Service-principal env vars (any VM / CI)¶
export AZURE_CLIENT_ID=$APP_ID
export AZURE_TENANT_ID=$TENANT_ID
export AZURE_CLIENT_SECRET=$CLIENT_SECRET
DefaultAzureCredential picks these up automatically. Use a secrets
manager (HashiCorp Vault, Kubernetes Secrets, Azure Key Vault — yes,
ironically) to deliver AZURE_CLIENT_SECRET; don't bake it into
process startup scripts.
Option C — Managed Identity (AKS / Container Apps / VM Scale Sets)¶
Easiest and most secure. The runtime injects an identity that Yutha never sees credentials for.
AKS via Workload Identity:
# Bind the K8s ServiceAccount to the Azure-side SP.
az identity federated-credential create \
--name yutha-fed \
--identity-name $SP_NAME \
--resource-group $RG \
--issuer $(az aks show -n <cluster> -g $RG --query oidcIssuerProfile.issuerUrl -o tsv) \
--subject "system:serviceaccount:$NAMESPACE:$KSA_NAME"
# Annotate the K8s SA.
kubectl annotate sa $KSA_NAME -n $NAMESPACE \
azure.workload.identity/client-id=$APP_ID
No env vars needed on the pod. DefaultAzureCredential detects the
projected service account token automatically.
6. Tell the control plane to use Azure Key Vault¶
--signer azure-kv plus two required flags wires AzureKvSigner
into the control-plane binary. Auth is via DefaultAzureCredential
(configured in §5) — no Yutha-side credential flag.
./yutha \
--signer azure-kv \
--signer-azure-kv-vault-url "https://$HSM_NAME.managedhsm.azure.net" \
--signer-azure-kv-key-name $KEY_NAME \
--signer-azure-kv-key-version "<32-char-hex from §2>" \
[other flags from the quickstart]
--signer-azure-kv-key-version is technically optional (without it
the signer takes the latest version) but strongly recommended in
production — leaving it unset means Azure can auto-rotate the
underlying key behind your back and invalidate signatures from the
perspective of any verifier that cached the old public key. Pin per
RFC 0017 §3.6.
Every flag has a matching YUTHA_SIGNER_AZURE_KV_* env var
(YUTHA_SIGNER_AZURE_KV_VAULT_URL, YUTHA_SIGNER_AZURE_KV_KEY_NAME,
YUTHA_SIGNER_AZURE_KV_KEY_VERSION) if you prefer env-driven config.
The bootstrap-seed flow from the quickstart is no
longer needed for the control plane's identity — that identity now
lives in the Managed HSM. The bootstrap agent is unaffected (still
in-process, seedable via YUTHA_BOOTSTRAP_SEED if set) — see the
enterprise identity walkthrough for the
two-identity distinction.
When the server starts you should see:
INFO yutha_signer_azure_kv: yutha-signer-azure-kv connected
vault_url=https://yutha-hsm.managedhsm.azure.net
key_name=bootstrap
key_version=a1b2c3d4...
If vault_url doesn't end in .managedhsm.azure.net, you'll see a
WARN line above the connect message — that's the tier-mismatch
guardrail firing (see §10 below).
7. Verify end-to-end¶
Run any existing Yutha integration test against this control plane:
cd sdks/python
YUTHA_CONTROL_PLANE_ADDR=http://127.0.0.1:7000 \
pytest tests/test_integration.py::test_full_lifecycle -v
If you tail Managed HSM audit logs during the run (assuming you've
enabled them via az monitor diagnostic-settings), you'll see one
Sign operation entry per Yutha signing event.
8. Failure modes you'll hit in practice¶
| Symptom (operator-visible) | Likely cause | Where to look |
|---|---|---|
SignerError::BackendRejected mentioning 403 / Forbidden at startup |
The SP doesn't have the Managed HSM Crypto User role on this key. | az role assignment list --hsm-name <hsm> --query "[?principalId=='$SP_OID']". |
SignerError::BackendRejected mentioning 401 / Unauthorized |
DefaultAzureCredential couldn't resolve. Env vars unset, az login expired, no managed identity attached. |
az account show (CLI path); pod logs for managed-identity probes. |
SignerError::BackendRejected mentioning 404 |
Wrong vault URL, wrong key name, or KEY_VERSION pointing at a destroyed version. |
az keyvault key list --hsm-name <hsm>; verify vault_url exactly matches. |
SignerError::UnsupportedAlgorithm at startup |
The key isn't OKP-HSM/Ed25519 (you accidentally created an RSA or EC key, or you're pointed at a standard Key Vault). |
az keyvault key show --hsm-name <hsm> --name <key> --query "key.{kty,crv}". |
SignerError::BackendUnavailable during signing |
Azure transient, throttle (429), or DNS / TLS hiccup. | Azure status; check kubectl logs for retry behaviour. |
WARN yutha-signer-azure-kv: vault URL does not look like a Managed HSM at startup |
URL is *.vault.azure.net instead of *.managedhsm.azure.net. |
Standard Key Vault doesn't support Ed25519 — create or use a Managed HSM. |
| Sign latency higher than 50–150 ms | Cross-region traffic, or HSM partition contention. | Co-locate the control plane with the HSM region. |
9. Rotating the key¶
Yutha v1 does not support live version-following — the public key is
cached at connect() and held until process restart. To rotate:
# Create a new version.
az keyvault key create \
--hsm-name $HSM_NAME \
--name $KEY_NAME \
--kty OKP-HSM \
--curve Ed25519 \
--ops sign verify \
--query "key.kid" -o tsv
# Update YUTHA_SIGNER_AZURE_KV_KEY_VERSION to the new hex + restart.
Plan for a restart at rotation time. Real live-rotation hooks are tracked under the operator-credentials follow-ons memo.
Don't destroy the old version immediately. Receipts signed under it are still verifiable against its public key for the lifetime of any external verifier that cached it. Use
az keyvault key set-attributesto disable rather than destroy when you're done.
10. What this does NOT change¶
The Azure Key Vault Signer is a custody swap, not a protocol swap. None of these change when you adopt it:
- Receipt canonical bytes, topology semantics, constitution evaluator, enforcement loop, operator credentials.
- Yutha's logs — only thing that moves is where the key bytes live.
The only differences an observer would see externally: ~50–150 ms extra signing latency, and one Managed HSM audit entry per signing event.
What's next¶
- GCP KMS or HashiCorp Vault: the sibling crates
yutha-signer-gcp-kmsandyutha-signer-vaultfollow the same connect-and-sign pattern with their own walkthroughs. - AWS KMS: not supported in v1; AWS KMS does not have native Ed25519. Use the Vault Signer walkthrough on AWS.
- Attestor: signing custody (this doc) is one of two enterprise-identity seams. The other is identity verification via SPIFFE/SPIRE or OIDC, landing in Phase E / F.