Holding the bootstrap key in HashiCorp Vault¶
A 30-minute walkthrough for the operator who wants to move Yutha's bootstrap signing key out of process memory and into HashiCorp Vault's transit secrets engine. By the end you'll have:
- A Vault transit Ed25519 key the substrate signs against on every passport / envelope / capability / bearer mint / receipt operation.
- A Vault policy that grants the control-plane process exactly the
two capabilities it needs (
readon the key,updateonsign). - An auth path (Token or AppRole) the control plane uses to acquire its Vault client token.
- A
VaultSignerwired through the control plane, with the in-process signer no longer present.
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
VaultSigner is a single construction-site change. No call paths
move. No receipt formats change. Signatures stay byte-for-byte the
same — Ed25519 is deterministic, and Vault implements RFC 8032 just
like the in-process path does.
What you write. The Vault address, the policy, the auth method choice, and the env-var configuration that points the control plane at this backend.
If you haven't operated a Yutha swarm before, read the operator quickstart first — it covers the bootstrap seed model and the gRPC server flags this doc assumes you're comfortable with.
This walkthrough implements the Vault-transit backend pinned by RFC 0017 §4.1, which itself is the AWS-friendly enterprise custody path nominated by RFC 0015 §9.1 (since AWS KMS does not support Ed25519 today).
Prerequisites¶
- Vault 1.13 or later. Vault OSS, HCP Vault, or self-hosted
Enterprise all work. The free
vault server -devmode is fine for this walkthrough. - The
vaultCLI v1.13+ available on your shell path. - A Yutha control-plane checkout at
crates/yutha-control-plane(this walkthrough exercises the binary). - About 10–20 ms of additional sign latency budget per envelope —
Vault transit is network-bound, vs.
InProcessSigner's in-memory Ed25519 of well under 100 µs.
You do not need any cloud account, KMS provisioning, or PKCS#11 hardware. The whole walkthrough runs against a local Vault dev container.
1. Stand up a Vault dev server¶
A one-off container is enough for this walkthrough. In production you'd point at your existing Vault cluster instead.
docker run --rm -d \
--name yutha-vault \
-p 8200:8200 \
-e VAULT_DEV_ROOT_TOKEN_ID=dev-root \
-e VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200 \
hashicorp/vault:1.17
export VAULT_ADDR=http://127.0.0.1:8200
export VAULT_TOKEN=dev-root
vault status
vault status should print Initialized true / Sealed false. The
dev-server token (dev-root) is over-privileged on purpose — we'll
provision a least-privilege token below before pointing Yutha at
this Vault.
Production note. Plain HTTP is only OK for a dev container.
VaultSigner::connectwill warn but proceed if you point it athttp://.... Production deployments MUST use HTTPS — Vault transit signs over the wire, and an attacker on the network can otherwise tamper with sign requests/responses.
2. Enable transit + create the Ed25519 key¶
The transit engine is what gives Vault its sign / verify / encrypt RPCs. We only need sign.
vault secrets enable transit
vault write -f transit/keys/yutha-bootstrap type=ed25519
vault read transit/keys/yutha-bootstrap
The read output should show type=ed25519 and a keys map with
version 1 and a base64-encoded public_key. That's the Ed25519
public key your Yutha swarm will sign against — it's safe to copy
into your monitoring / runbooks / out-of-band attestation channels.
You created the key without a derivation context (derived=false),
which is what we want — Yutha signs canonical bytes directly, no
per-request derivation.
3. Write the least-privilege policy¶
Yutha needs exactly two capabilities:
readontransit/keys/yutha-bootstrap— to discover the public key atconnect()time.updateontransit/sign/yutha-bootstrap— to sign messages.
Nothing else. No delete, no rotate, no second key path. The
policy doc:
cat > /tmp/yutha-signer-policy.hcl <<'EOF'
path "transit/keys/yutha-bootstrap" {
capabilities = ["read"]
}
path "transit/sign/yutha-bootstrap" {
capabilities = ["update"]
}
EOF
vault policy write yutha-signer /tmp/yutha-signer-policy.hcl
The principle here is the same one as Yutha's capability layer: hand out exactly the rights the consumer needs, and no more.
4. Pick an auth method¶
yutha-signer-vault supports Token and AppRole end-to-end.
Pick one:
Option A — Token auth (simplest)¶
Mint a periodic token scoped to the yutha-signer policy:
vault token create \
-policy=yutha-signer \
-period=24h \
-display-name="yutha-control-plane" \
-format=json | jq -r '.auth.client_token'
Write the token to a file the control-plane process can read.
Yutha takes the Vault token as a file path (not a raw flag value or
env var) so the secret never appears in ps aux, shell history, or
process-listing scrapes:
mkdir -p ~/.yutha
# Paste the token between the quotes.
echo -n '<paste the token here>' > ~/.yutha/vault-token
chmod 600 ~/.yutha/vault-token
A periodic token renews itself on every read up to its period, so
as long as the control plane calls lookup-self / renew-self
periodically (or you run a Vault Agent sidecar that rewrites the
file before expiry), the token doesn't expire.
Option B — AppRole auth (recommended for cloud VMs)¶
AppRole gives the control plane a role_id (durable, may be baked
into config) + a secret_id (rotated, typically delivered via some
out-of-band trust path). The Vault Signer logs in once at
connect(), gets a client token, and uses it for the rest of the
process lifetime.
vault auth enable approle
vault write auth/approle/role/yutha-control-plane \
token_policies=yutha-signer \
token_period=24h \
bind_secret_id=true
ROLE_ID=$(vault read -field=role_id auth/approle/role/yutha-control-plane/role-id)
SECRET_ID=$(vault write -f -field=secret_id auth/approle/role/yutha-control-plane/secret-id)
# role_id is durable and not secret — env var or flag is fine.
# secret_id IS secret — write to a file the control-plane process
# can read, same posture as the Token path above.
mkdir -p ~/.yutha
echo -n "$SECRET_ID" > ~/.yutha/vault-secret-id
chmod 600 ~/.yutha/vault-secret-id
echo "ROLE_ID: $ROLE_ID"
echo "secret_id file: ~/.yutha/vault-secret-id"
The secret_id is single-use by default — once VaultSigner::connect
exchanges it for a client token at process startup, the secret_id is
consumed. To run again you mint a fresh one (or configure
bind_secret_id=false if your threat model allows it).
5. Tell the control plane to use Vault¶
--signer vault plus four flags wire VaultSigner into the
control-plane binary. The exact flag set depends on the auth method
you picked in §4.
Token auth:
./yutha \
--signer vault \
--signer-vault-addr "$VAULT_ADDR" \
--signer-vault-key yutha-bootstrap \
--signer-vault-token-file ~/.yutha/vault-token \
[other flags from the quickstart]
AppRole auth:
./yutha \
--signer vault \
--signer-vault-addr "$VAULT_ADDR" \
--signer-vault-key yutha-bootstrap \
--signer-vault-approle-role-id "$ROLE_ID" \
--signer-vault-approle-secret-id-file ~/.yutha/vault-secret-id \
[other flags from the quickstart]
The full per-backend flag matrix (including the optional
--signer-vault-mount, --signer-vault-namespace,
--signer-vault-approle-mount) is documented inline with
./yutha --help and on the
crate README.
Every flag has a matching YUTHA_SIGNER_VAULT_* env var if you
prefer env-driven config; secrets always go via a *_FILE flag
(--signer-vault-token-file ↔ YUTHA_SIGNER_VAULT_TOKEN_FILE,
--signer-vault-approle-secret-id-file ↔
YUTHA_SIGNER_VAULT_APPROLE_SECRET_ID_FILE) — never as raw values.
The bootstrap-seed flow from the quickstart is no
longer needed for the control plane's identity — that identity now
lives in Vault. The bootstrap agent still uses an in-process key
seeded by YUTHA_BOOTSTRAP_SEED if you've set it (per the
enterprise identity walkthrough — the two
identities are separate).
When the server starts you should see a log line like:
INFO yutha_signer_vault: yutha-signer-vault connected
mount=transit key_name=yutha-bootstrap
address=http://127.0.0.1:8200
That's VaultSigner::connect having authenticated, fetched the
public key, and cached it. From this point every passport / envelope
/ capability / bearer-token / receipt signing call is a network
round-trip to Vault.
Library users: the
VaultConfig::from_env()helper consumes a separate env-var family (YUTHA_SIGNER_VAULT_TOKEN,YUTHA_SIGNER_VAULT_APPROLE_SECRET_ID) that holds raw secret values, intended for programmatic use of the crate outside the control-plane binary. Operators should not set those env vars — the control plane never reads them, and using them mixes the file- path posture above with raw secrets in process env.
6. Verify end-to-end¶
Run any existing Yutha integration test against this control plane.
The test_integration::test_full_lifecycle Python test (from the
sdks/python/ directory) is a good end-to-end smoke:
cd sdks/python
YUTHA_CONTROL_PLANE_ADDR=http://127.0.0.1:7000 \
pytest tests/test_integration.py::test_full_lifecycle -v
If you watch the Vault audit log in another shell during the run,
you'll see one transit/sign/yutha-bootstrap line per sign event:
the bootstrap passport, the operator passport, every envelope and
capability the test mints. Yutha's logs are unchanged — the only
thing that moved is where the key bytes live.
To turn the audit log on:
7. Failure modes you'll hit in practice¶
| Symptom (operator-visible) | Likely cause | Where to look |
|---|---|---|
SignerError::BackendRejected mentioning HTTP 403 at startup |
Token or AppRole credentials don't have the yutha-signer policy attached. |
vault token lookup <token> — check policies. |
SignerError::BackendRejected mentioning HTTP 404 at startup |
Key name wrong, mount path wrong, or the key was never created. | vault read transit/keys/<key> should succeed under the token in use. |
SignerError::UnsupportedAlgorithm at startup |
The transit key isn't type=ed25519. |
vault read transit/keys/<key> — type field. Delete the wrong-type key and recreate. |
SignerError::BackendUnavailable during a sign |
Vault sealed, network blip, or the cluster is down. | vault status; check the audit log + Vault server logs. The substrate's caller (passport mint, envelope send, etc.) returns a transient error; retry policy is the caller's. |
| Sign latency jumps from ms to seconds | Vault is in a different region than your control plane. | Co-locate. The expected steady-state is 5–20 ms intra-region, 50–150 ms cross-region. |
The control plane starts but every gRPC call fails with FAILED_PRECONDITION |
A constitution has not been activated. Vault has nothing to do with this — see operator credentials. | The constitution layer runs in-process; the Signer doesn't touch it. |
8. Rotating the Vault key¶
Yutha v1 does not yet support live key rotation — the public key is
cached at connect() and held until process restart. To rotate:
vault write -f transit/keys/yutha-bootstrap/rotate(creates version 2).- Either:
- Restart the control plane to pick up the new version (simplest for closed swarms), or
- Use the operator-revoke RPC and
rotate_keyadmission flow to redirect agents at a new key path (see operator credentials).
Real rotation hooks are tracked under the operator-credentials follow-ons memo and will land before the public 0.2 release. For now, plan for a restart at rotation time.
9. What this does NOT change¶
The Vault Signer is a custody swap, not a protocol swap. None of these change when you adopt it:
- Receipt canonical bytes. Same wire format, same Merkle tree, same
conformance vectors. The Phase B sign-and-verify
vectors
test the byte-equivalence of
InProcessSigneragainst rawSigningKey::sign_message; the per-backend integration test undercrates/yutha-signer-vault/tests/integration.rsasserts the same RFC 8032 verify-roundtrip property holds for Vault. - Topology semantics. Closed / open / hybrid behave the same.
- Constitution evaluator + enforcement loop. All in-process; Vault has nothing to do with them.
- Operator credentials (RFC 0009). The operator's signing key is the
same
Signertrait — if you point both at the same Vault, both go through Vault. The operator-credential workflow is unchanged.
The only difference an observer would see externally: a signature takes ~10–20 ms longer to mint, and a Vault audit-log entry exists for every signing event.
What's next¶
- GCP KMS or Azure Key Vault: the sibling crates
yutha-signer-gcp-kmsandyutha-signer-azure-kvship in Phase C-C / C-D and follow the same connect-and-sign pattern. Each has its own walkthrough under/docs/operator/. - AWS KMS: not supported in v1; AWS KMS does not have native Ed25519. The Vault path documented here is the recommended AWS-friendly approach. See RFC 0015 §9.1.
- Attestor: signing custody (what this doc covers) is one of
two enterprise-identity seams. The other is identity verification
via SPIFFE/SPIRE or OIDC, which lands in Phase E / F. See
/spec/identity-keys/README.md.