Identity signing-key and authority-fence provisioning
Source of truth:
docs/runbooks/identity-signing-key-provisioning.md(synced at build time — edit the original, not this copy). This runbook covers provisioning the identity signing key ring and the authority fence for the Veltro identity authority — the ES256 key(s) the gateway-authorized request-assertion surface signs with, and the single-row fence that names which deployment may mint sessions at the current generation. Both belong to standing up a fresh identity database. Key rotation and fence advances (signing-tool fence-advance, break-glass) are separate, deliberate operator actions and are not covered here as boot steps.
Why this exists
Section titled “Why this exists”Every gateway-authorized product route (/sigmatic/…, /warden/…) is gated by an
internal nginx auth_request to /api/authority/browser-assertion, which mints
a browser request assertion through the signing key ring. The ring is persisted
encrypted in veltro.identity_signing_keys (owned migration 007), protected
by an out-of-band passphrase. Migration 007 creates the table but no row: a
fresh identity database starts with an empty ring, and buildSigningKeyRing
fails closed (signkey_ring_empty) rather than mint unsigned. An unprovisioned
composed boot therefore 500s every authorized route while looking
otherwise healthy — the identity readiness probe does not exercise minting.
Boot-time provisioning: identity-init (automatic, idempotent)
Section titled “Boot-time provisioning: identity-init (automatic, idempotent)”In Compose, one identity-init service does all of identity’s bootstrap
(#1690). It runs the same pinned identity image
(node apps/identity/signing-tool/boot-init.cjs) once per up and exits.
In order, it:
- applies the owned migrations;
- mints the first active signing key when the ring is empty;
- claims the deployment issuer and writes generation 1 of the authority fence when there’s no fence;
- creates identity’s own
veltro-identityworkload credential; - creates or re-emits one workload credential per installed product
(
VELTRO_PRODUCTS) and writes its secret to/run/suite-workload-secrets/<product>; - creates the TOTP keyring in the
identity-keyringvolume when there isn’t one (see below).
It’s the only service that gets the owner migration DSN
(VELTRO_IDENTITY_MIGRATION_DATABASE_URL). It also gets the signing
passphrase, both from the environment, never on a command line. The veltro
identity service and every product backend wait for
condition: service_completed_successfully on it, so nothing is served before
the ring holds a key and the fence is claimable. Its output is one line of
booleans and counts per step, never a key, DSN or issuer id.
Every step is idempotent. A re-run with an active key keeps it and exits 0: it never adds, rotates or replaces a key, so a second key in the ring on its own is a tamper or operator signal.
scripts/validate_identity_credentials.py (run by make pull / make up
before Docker) fails closed when VELTRO_IDENTITY_SIGNING_PASSPHRASE is
present but shorter than the 12-character minimum the sealing envelope
enforces (assertPassphrase), so a boot that can’t provision fails at its
source, naming only the variable.
Helm still runs these steps as separate Jobs (job-migrate,
job-provision-signing-key, job-provision-authority-fence,
job-provision-identity-workload), from the boot-*.cjs bundles in the same
image.
The TOTP keyring
Section titled “The TOTP keyring”The keyring seals TOTP factors, the upstream OIDC client secret, the SCIM
token and notification destination secrets. identity-init keeps it at
/var/lib/veltro-identity/keyring/destination-totp-keyring.json in the
identity-keyring volume (veltro-identity-keyring), owned by uid 1000 at
mode 0600. Identity mounts the same volume read-only.
- No keyring and nothing sealed in the database:
identity-initcreates one. - A keyring there: it’s checked with identity’s own loader and never
rewritten. It also has to hold every key id the database stores beside
sealed data. If one is missing (the wrong backup, the wrong host file),
identity-initstops and names the missing ids. - No keyring but the database holds sealed data:
identity-initstops with an error and creates nothing. A new key couldn’t open that data. Restore the keyring (from a backup, or from the host file below) and runupagain.
Backups seal the volume’s archive under VELTRO_BACKUP_PASSPHRASE, and
restore puts it back (see backup-restore).
Moving the TOTP keyring into its volume
Section titled “Moving the TOTP keyring into its volume”Before #1690 the keyring was a host file: VELTRO_IDENTITY_TOTP_KEYRING_FILE_SOURCE
in .env, relative paths under compose/, usually
compose/secrets/veltro-identity/destination-totp-keyring.json. That file
has to land in the volume before identity-init first runs.
On a CLI install you don’t do anything. veltro upgrade, veltro up,
veltro install, restore and rollback check for that host file before they
start the stack. When it’s there, a short-lived identity-init container
(docker compose run --rm --no-deps, the pinned image, the host file mounted
read-only) copies it into the volume if the volume has no keyring yet, hands
it to uid 1000 at 0600, and leaves the host file where it is. Once the copy
is there, later starts see the same bytes and move on.
If the volume already holds a different keyring, the CLI stops and changes
nothing. Usually that means identity-init made a fresh keyring because a
plain docker compose up ran before the copy. Two ways out:
-
The volume’s keyring is the right one (say you just restored a backup): move the host file aside and remove
VELTRO_IDENTITY_TOTP_KEYRING_FILE_SOURCEfrom.env. -
The host file is the one your data is sealed with and nothing new was enrolled since: stop the stack, remove the volume’s keyring, and start again so the copy runs:
Terminal window docker run --rm -v veltro-identity-keyring:/k alpine:3.20 \rm /k/destination-totp-keyring.jsonveltro up
Homelab promotion can’t do this step. It only ever triggers DeployStack on
the Komodo stack, and a homelab overlay can’t add a service. Do it by hand on
the homelab Docker host, once, before you promote the first release with
identity-init. KEYRING is the absolute path the stack’s
VELTRO_IDENTITY_TOTP_KEYRING_FILE_SOURCE points at, and the labels match
the Compose project Komodo runs the stack as (the stack name, veltro):
KEYRING=/path/to/destination-totp-keyring.jsondocker volume create \ --label com.docker.compose.project=veltro \ --label com.docker.compose.volume=identity-keyring \ veltro-identity-keyringdocker run --rm -v veltro-identity-keyring:/k -v "$KEYRING":/src:ro alpine:3.20 sh -euc ' dst=/k/destination-totp-keyring.json if [ -e "$dst" ]; then cmp -s /src "$dst" && echo present && exit 0 echo "the volume holds a different keyring; nothing changed" >&2; exit 3 fi umask 077 cp /src /k/.seed.tmp && chown 1000:1000 /k/.seed.tmp && chmod 0600 /k/.seed.tmp ln /k/.seed.tmp "$dst" && rm /k/.seed.tmp && echo seeded'It prints seeded the first time and present after. Then promote. Also check
homelab-overlay.yml in komodo/veltro before that promotion: an entry for
identity-migrate, any identity-provision-* service, or a veltro.volumes
list that still binds the keyring file has to go, or the render refuses the
stale entry (a volumes list replaces the release’s whole list, which would
drop the identity-keyring mount). The stack’s
VELTRO_IDENTITY_TOTP_KEYRING_FILE_SOURCE and VELTRO_IDENTITY_TOTP_KEYRING_FILE
variables are unused afterwards.
When the old host file can go
Section titled “When the old host file can go”Keep the host keyring file until both of these are true:
- a backup taken on the new release exists. Its envelope holds the volume’s keyring, so the host file is no longer the only copy outside the volume;
- you won’t roll back to a release from before
identity-init. Those releases bind the host file into identity and never read the volume, so rolling back without it leaves identity without a keyring.
Then delete the file and remove VELTRO_IDENTITY_TOTP_KEYRING_FILE_SOURCE
(and VELTRO_IDENTITY_TOTP_KEYRING_FILE) from .env, or from the homelab
stack’s environment. Delete it before the next time you’d rotate the keyring,
too: once the volume’s keyring changes, the old file no longer matches it and
the next start stops on the difference.
Authority fence boot-time provisioning (automatic, idempotent)
Section titled “Authority fence boot-time provisioning (automatic, idempotent)”identity-init also provisions the deployment issuer and authority fence
(issue #752). Without these rows, credentials verify and
then prepareSession answers 503 SECURITY_CONFIGURATION_UNAVAILABLE on
every successful login — forever — because nothing else writes
veltro.deployment_issuer or veltro.identity_authority_fence.
Sequencing, if you are changing a bootstrap step. Compose runs
boot-init.cjsout of the pinned identity image, and that file exists only because the protected publication job bundles it. A step identity waits on therefore can’t ship before the pinned image contains it: the container exitsMODULE_NOT_FOUND,service_completed_successfullynever holds, and the whole deployment stops at boot instead of merely refusing logins. Since ADR-016 a release builds the identity image from its own commit and writes that version intoversions.env, so a release always carries the bundle its compose file runs. The hand-setVELTRO_VERSIONon main can predate a step, so a stack started from a main checkout needs a released pin (or asha-<commit>image) that has it.
Sequencing, the other direction: a receiver and its material move together. The gateway mints a browser request assertion on every product route, and a correct receiver fails closed on a presented assertion it cannot verify — falling back to the session cookie after a presented credential fails verification would be a bypass. So the coupling has two failure modes, not one:
- Restoring the gateway’s
Cookiestrip before the release carrying the receiver is pinned refuses every browser request (veltro#758, #772).- Pinning a release that contains a receiver before that product’s compose block carries its
SUITE_AUTHORITY_V2_*settings refuses every browser request too.The second cost a release train: WARDEN 0.11.1 carried warden#138’s receiver before Warden got its keys, and
/warden/api/auth/mewent 200 → 401 on the version bump by itself. Give the product’s compose block the issuer, generation, audience and identity’s JWKS URL (http://veltro:3000/.well-known/jwks.json) in the same change that moves the pin.Each product’s
SUITE_AUTHORITY_V2_AUDIENCEis a compose literal. The suite.envis shared, so one scalar there resolves every product to the first audience and each receiver then refuses for “wrong audience” (veltro#775).
Idempotency and refusal
Section titled “Idempotency and refusal”- Matching fence (restart): if a fence already exists and names this deployment, the fence step is a proven no-op — same issuer, same generation, no new fence event.
- Foreign fence (do not soften): if a fence exists naming a different
deployment,
identity-initfails closed and writes nothing. A booting container must never take authority from the active issuer and must never “repair” or adopt that fence. That refusal is the security property the cutover fence (#130 / #219) rests on; an update/adopt branch here would make the fence meaningless. - Generation: boot never advances
authority_generation. Only the break-glasssigning-tool fence-advancecommand moves it. Products pin the generation they accept, so after a fence-advance setSUITE_AUTHORITY_V2_ACTIVE_GENERATIONin.envto the new value and runveltro up. On Helm, set the Sigmatic and Warden generation values and runhelm upgrade; the publish Job writes VectorFlow’s from the fence.
Verify
Section titled “Verify”docker exec veltro-suite-postgres \ psql -U veltro_identity_owner -d veltro_identity -XAtc \ "SELECT di.deployment_id, f.active_issuer_id, f.authority_generation FROM veltro.deployment_issuer di CROSS JOIN veltro.identity_authority_fence f WHERE di.id = 1 AND f.id = 1"Expect one row where deployment_id = active_issuer_id and
authority_generation >= 1. A successful login (POST /api/auth/login) must
return HTTP 200 and mint a session cookie — never HTTP 503
SECURITY_CONFIGURATION_UNAVAILABLE on a correct credential.
Manual first provisioning (out-of-band identity install)
Section titled “Manual first provisioning (out-of-band identity install)”If you are standing up identity by hand without the composed core profile,
after migrations and before starting the identity service:
export VELTRO_IDENTITY_MIGRATION_DATABASE_URL='postgresql://veltro_identity_owner:***@suite-postgres:5432/veltro_identity'node apps/identity/signing-tool/boot-authority-fence.cjs# -> issuer_created=true fence_created=true authority_generation=1(From a source checkout before the image bundle exists, the TypeScript entry is
pnpm --filter @veltrosecurity/identity exec tsx signing-tool/boot-authority-fence.ts.)
After an out-of-band provision, make up boots normally: identity-init
sees the matching fence and keeps it.
Verify the signing-key ring
Section titled “Verify the signing-key ring”On the running stack, the ring must hold exactly one active key:
docker exec veltro-suite-postgres \ psql -U veltro_identity_owner -d veltro_identity -XAtc \ "SELECT key_id,status FROM veltro.identity_signing_keys WHERE realm_id='default' ORDER BY created_at"Through the gateway, GET /sigmatic/ (and every other product route) must return
its real application response — never HTTP 500.
Manual first provisioning (out-of-band identity install)
Section titled “Manual first provisioning (out-of-band identity install)”If you are standing up identity by hand (import tooling, recovery rehearsal,
or a deployment without the composed core profile), provision the ring right
after migrations, before starting the identity service:
export VELTRO_IDENTITY_MIGRATION_DATABASE_URL='postgresql://veltro_identity_owner:***@suite-postgres:5432/veltro_identity'# VELTRO_IDENTITY_SIGNING_PASSPHRASE — never on a command line:# export VELTRO_IDENTITY_SIGNING_PASSPHRASE='<long, random, out-of-band>'pnpm --filter @veltrosecurity/identity signing provision# -> provisioned signing key kid-<hex> (active)provision reads the passphrase from VELTRO_IDENTITY_SIGNING_PASSPHRASE (or
a muted stdin prompt), requires the owner DSN, and refuses when an
active key already exists — that refusal means the ring is already provisioned
and is not an error to paper over. It prints only the key id.
The passphrase must be at least 12 characters and is the same value the
identity runtime reads in .env as VELTRO_IDENTITY_SIGNING_PASSPHRASE;
it is the key-encryption secret for every envelope in the ring, so it must be
escrowed out-of-band and never committed.
After an out-of-band provision, make up boots normally: identity-init
sees the active key and keeps it.
Rotation and passphrase change (do NOT do at boot)
Section titled “Rotation and passphrase change (do NOT do at boot)”Rotation is signing-tool rotate: it retires the active key to verify-only
and mints a new active key. Restart identity afterwards so it loads the new
ring. Products fetch identity’s public keys from its JWKS
(/.well-known/jwks.json, veltro#1692), which lists the active key and every
retired one, so assertions signed before the rotation still verify. A product
that sees a key id it doesn’t know fetches the JWKS again, so it picks up the
new key on its first assertion without a restart.
Passphrase rotation is rekey (see the same CLI’s help). Both are operator
ceremonies with their own sequencing. identity-init never rotates: if the
ring has no active key at boot and one is needed, restore/import it through
the recovery tooling rather than fabricating a new key at startup.