Skip to content

feat(identityhub): provision and rotate the participant identity from the chart - #173

Draft
vramperez wants to merge 20 commits into
mainfrom
feat/edc-production-readiness
Draft

feat(identityhub): provision and rotate the participant identity from the chart#173
vramperez wants to merge 20 commits into
mainfrom
feat/edc-production-readiness

Conversation

@vramperez

Copy link
Copy Markdown
Collaborator

A deployed identityhub was inert: the chart had no template, Job or values key that
talks to the Identity API, writes the signing key to vault or inserts a credential.
The procedure was documented as a manual curl sequence in doc/DSP_INTEGRATION.md and
only automated inside the integration tests, so every real deployment reimplemented it
as a hand-written Job.

This adds it as a chart feature, keeping upstream's payloads and helper logic:

  • derive-key.sh derives the JWK pair from the participant's identity key, EC (P-256
    and P-384) and RSA. The algorithm is explicit because the failure mode is silent:
    extracting an RSA modulus from an EC key yields a valid-looking JWK full of garbage
    that only breaks later, at the counterparty.
  • register.sh writes the key to vault, repairs the super-user credential that
    SuperUserSeedExtension leaves in an unusable shape, registers the participant
    context idempotently and provisions the STS client secret. That secret is chosen by
    us rather than read from the creation response, which is the only way to make it
    survive a restart, and it is written under both spellings of the alias because EDC's
    vault client double-encodes the colons in a DID.
  • check-kid.sh verifies the issuer signs with # and aborts otherwise,
    printing the one-off repair. It cannot fix it: Keycloak never re-imports an existing
    realm and kid is not a declared property of its java-keystore provider, so an
    Admin API PUT answers 204 and drops it.
  • insert-credential.sh publishes the credential into the identityhub's own store,
    replacing a stale copy, and refuses a credential signed under a different kid.
  • rotate-key.sh replaces the signing key, keeping the alias and the DID fragment.

The rotation Job is opt-in and never a hook: there is no overlap window, so each run
invalidates everything signed with the previous key and re-issuance is part of the
operation. Its order - revoke, overwrite the vault alias, add - is the only safe one:
the keypairs rotate endpoint called with the same keyId appends a second
verificationMethod under the same id, and revoking either one then removes both.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com

vramperez and others added 17 commits August 17, 2026 11:07
… the chart

A deployed identityhub was inert: the chart had no template, Job or values key that
talks to the Identity API, writes the signing key to vault or inserts a credential.
The procedure was documented as a manual curl sequence in doc/DSP_INTEGRATION.md and
only automated inside the integration tests, so every real deployment reimplemented it
as a hand-written Job.

This adds it as a chart feature, keeping upstream's payloads and helper logic:

  * derive-key.sh derives the JWK pair from the participant's identity key, EC (P-256
    and P-384) and RSA. The algorithm is explicit because the failure mode is silent:
    extracting an RSA modulus from an EC key yields a valid-looking JWK full of garbage
    that only breaks later, at the counterparty.
  * register.sh writes the key to vault, repairs the super-user credential that
    SuperUserSeedExtension leaves in an unusable shape, registers the participant
    context idempotently and provisions the STS client secret. That secret is chosen by
    us rather than read from the creation response, which is the only way to make it
    survive a restart, and it is written under both spellings of the alias because EDC's
    vault client double-encodes the colons in a DID.
  * check-kid.sh verifies the issuer signs with <did>#<keyAlias> and aborts otherwise,
    printing the one-off repair. It cannot fix it: Keycloak never re-imports an existing
    realm and `kid` is not a declared property of its java-keystore provider, so an
    Admin API PUT answers 204 and drops it.
  * insert-credential.sh publishes the credential into the identityhub's own store,
    replacing a stale copy, and refuses a credential signed under a different kid.
  * rotate-key.sh replaces the signing key, keeping the alias and the DID fragment.

The rotation Job is opt-in and never a hook: there is no overlap window, so each run
invalidates everything signed with the previous key and re-issuance is part of the
operation. Its order - revoke, overwrite the vault alias, add - is the only safe one:
the keypairs `rotate` endpoint called with the same keyId appends a second
verificationMethod under the same id, and revoking either one then removes both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…figMaps

Two separate production blockers around the bundled vault.

The token was rendered into ConfigMaps in clear - identityhub-cm.yaml did it with a
`required`, so the chart could not even render an identityhub without a plaintext token
in values - and anyone able to read ConfigMaps in the namespace could read the
credential that unlocks the participant's signing key. vault.hashicorp.existingSecret
now injects it as EDC_VAULT_HASHICORP_TOKEN instead, and the ConfigMap omits the
property entirely in that case rather than leaving an empty one behind for EDC to
resolve as an empty token.

The vault itself ran in dev mode, which is in-memory: every restart of that pod loses
the participant's signing key, the identityhub super-user credential, the STS client
secret and the transfer signing key fdsc-edc generates for itself, taking the DCP lane
down until the bootstrap Job runs again. Persistent storage alone does not fix it,
because a vault with storage comes back sealed and refuses every request. So
vault.production adds the missing half: initialise an empty vault once, store the unseal
keys in a Secret, and unseal whenever the vault is found sealed. It is a Deployment and
not a Job because the restart that needs it happens long after any deploy.

The trade-off is written into values.yaml rather than hidden: the unseal keys live in a
Kubernetes Secret, which is what makes an unattended restart recover on its own, and
autoUnseal: false leaves them out of the cluster at the cost of a manual unseal. The
Secret is annotated to survive uninstall, since deleting it while the storage volume
exists leaves a vault nobody can open. Production mode also refuses to render alongside
dev mode instead of silently doing nothing.

The unsealer runs under its own ServiceAccount with a Role scoped to the single Secret
it owns, so it cannot read the participant's identity key or its credentials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eval.sh renders every chart with default values only, and identityhub, vault, fdsc-edc
and vcCredentials are all disabled by default - so none of those templates was ever
reached by `helm template` or kubeconform. A broken manifest in that whole area passed
CI silently.

Each chart's ci/*-values.yaml is now rendered and validated as well. The first one turns
on the EDC lane, including the opt-in rotation Job and the branch that keeps the vault
token out of the ConfigMap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each of these cost time in a production deployment and none of them is visible from a
default render, which is why they survived: with identityhub, vault and fdsc-edc all
disabled by default, none of this code is exercised.

Dangling references, i.e. templates that named objects nothing created:

  * identityhub-custom-cacerts: declaring identityhub.customCaCerts mounted an empty
    emptyDir over the JVM truststore, replacing the bundled roots with nothing instead of
    extending them. Now a ConfigMap holds the extra CAs and an init container imports
    them into a writable copy of the truststore - which is what makes a vault or an issuer
    behind a private CA reachable at all.
  * identityhub-pvc-logs: enabling logging.persistence rendered a Deployment whose pod
    could never be scheduled.

The trusted-issuers registration was effectively install-only. It is annotated
post-install, so a release that already exists never runs it again, and the generated
script used `curl -f`, which turns the 409 of an already-registered issuer into a failed
Job - so making it run on upgrade would have failed every time. Both fixed: the script
now treats 409 as success and reports the status code, and the hook includes
post-upgrade. This is what kept a change to the credentials configuration from ever
reaching a running cluster.

Defaults that cannot work or should not be defaults:

  * fdsc-edc tmfExtension was enabled with seven empty addresses. TMFConfig converts them
    with URI.create(...).toURL() while the runtime boots, so the default configuration
    aborted startup with "URI is not absolute". Off by default now, and the k3s values
    that relied on the old default enable it explicitly.
  * oid4vp.trustAll defaulted to true - disabling TLS verification, and the only
    DO-NOT-USE-IN-PRODUCTION marker in the chart.
  * edc.log.level defaulted to DEBUG, which logs every DSP message including token
    contents.

Credential attributes in the realm:

  * vc.expiry_in_seconds and vc.refresh_interval_in_seconds are now kept in sync. The
    admin console shows the first while the issued `exp` comes from the second, so
    setting only one produced credentials that expired at a completely different time
    than the console claimed - with Keycloak's default, a week. Setting neither still
    leaves Keycloak's defaults alone.
  * vc.signing_key_id defaults to the key id the DID document actually publishes when the
    identityhub serves that document. Keycloak otherwise defaults it to the bare DID,
    which is not resolvable there, and issuance fails with "No key for id '<did>' and
    algorithm ... available".

Also: the accounts port is opened by the deployment but was missing from the service, so
the STS Accounts API was unreachable in-cluster; and Chart.yaml asked for keycloak
0.21.7 while Chart.lock and the vendored archive are at 0.21.19, so the next
`helm dependency update` would have silently downgraded Keycloak.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…renewal

The chart pushed the credential into the identityhub's credential store once, from
the bootstrap Job, and nothing kept that copy in step afterwards. The vc-operator
renewed the Secret on its own schedule while the identityhub went on presenting
the previous credential until it expired, which took the DCP lane down with
nothing having been deployed and a Secret that looked healthy.

vcCredentials.requests[].identityHub.enabled hands that job to the vc-operator,
which already knows when a credential is renewed. Every address defaults to what
identityhub.bootstrap declares, so a participant provisioned by this chart needs
nothing beyond the toggle.

The api key has to come from a Secret, because that is what the operator reads;
rendering fails with an explanation rather than emitting a dangling reference when
only a plaintext identityhub.superuser.key is configured.

Requires the vc-operator release that understands spec.identityHub.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The chart's only unit tests covered OpenTelemetry tracing, so nothing asserted
anything about the templates this branch adds or repairs. 26 tests over two
suites, aimed at the things that were actually wrong in real deployments rather
than at rendering in general:

  * the bootstrap steps run in a guaranteed order, and the credential insert is a
    regular container so a missing credential fails the Job instead of looping in
    an init retry;
  * the hook includes post-upgrade, which is what a dev-mode vault requires;
  * the key id is derived from the DID and the alias in one place;
  * the rotation Job carries no Helm hook and revokes before adding, never
    calling the keypairs rotate endpoint - which appends a duplicate
    verificationMethod under the same id;
  * the vault token is in the ConfigMap by default, and omitted rather than
    emptied when it comes from a Secret;
  * a production vault refuses to render next to dev mode;
  * the unsealer's Role is scoped to the single Secret it owns;
  * the objects the deployment referenced while nothing created them - the
    truststore ConfigMap with its import step, and the logs claim - now exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DSP_INTEGRATION.md described the participant setup as a curl sequence to run from a
workstation, which is why every deployment reimplemented it. The automated path is
now the recommended one and the manual procedure stays as the local-deployment and
debugging path, with the two traps that are easy to miss when doing it by hand
written down: why the STS client secret is chosen rather than read from the
creation response, and why it is stored under two spellings of the alias.

A new production guide covers what a default install does not: a Vault that
survives a restart with its unseal trade-off stated rather than hidden, a non-root
token kept out of the ConfigMaps, the bootstrap values, keeping the identityhub's
credential copy in step, pinning cert-manager's rotationPolicy instead of relying
on a default upstream intends to change, and the runbook for rotating the signing
key - including the step people skip, restarting everything that read the key at
startup and the apisix that caches the JWKS under an unchanged kid.

Also noted where the manual insert is described: a credential put there by hand is
a copy that nothing keeps current, which is the failure that takes the DCP lane
down without any deployment having happened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The EDC values render the vc-operator's CredentialIssuer and
VerifiableCredentialRequest, whose CRDs are in no public catalog, and
kubeconform -strict treats an unknown kind as an error - so the validation pass
those values were added for would itself have failed CI. Everything with a
published schema is still validated strictly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Aligning Chart.yaml with the lock removes the downgrade hazard but leaves the lock's
digest stale, so `helm dependency build` still refuses. Worth saying plainly rather
than letting the previous note read as if the drift were resolved: it comes from the
bump automation, which updates Chart.yaml only, and CI never noticed because
eval.sh ignores that command's exit status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the chart

A deployed identityhub was inert: the chart had no template, Job or values key that
talks to the Identity API, writes the signing key to vault or inserts a credential.
The procedure was documented as a manual curl sequence in doc/DSP_INTEGRATION.md and
only automated inside the integration tests, so every real deployment reimplemented it
as a hand-written Job.

This adds it as a chart feature, keeping upstream's payloads and helper logic:

  * derive-key.sh derives the JWK pair from the participant's identity key, EC (P-256
    and P-384) and RSA. The algorithm is explicit because the failure mode is silent:
    extracting an RSA modulus from an EC key yields a valid-looking JWK full of garbage
    that only breaks later, at the counterparty.
  * register.sh writes the key to vault, repairs the super-user credential that
    SuperUserSeedExtension leaves in an unusable shape, registers the participant
    context idempotently and provisions the STS client secret. That secret is chosen by
    us rather than read from the creation response, which is the only way to make it
    survive a restart, and it is written under both spellings of the alias because EDC's
    vault client double-encodes the colons in a DID.
  * check-kid.sh verifies the issuer signs with <did>#<keyAlias> and aborts otherwise,
    printing the one-off repair. It cannot fix it: Keycloak never re-imports an existing
    realm and `kid` is not a declared property of its java-keystore provider, so an
    Admin API PUT answers 204 and drops it.
  * insert-credential.sh publishes the credential into the identityhub's own store,
    replacing a stale copy, and refuses a credential signed under a different kid.
  * rotate-key.sh replaces the signing key, keeping the alias and the DID fragment.

The rotation Job is opt-in and never a hook: there is no overlap window, so each run
invalidates everything signed with the previous key and re-issuance is part of the
operation. Its order - revoke, overwrite the vault alias, add - is the only safe one:
the keypairs `rotate` endpoint called with the same keyId appends a second
verificationMethod under the same id, and revoking either one then removes both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…figMaps

Two separate production blockers around the bundled vault.

The token was rendered into ConfigMaps in clear - identityhub-cm.yaml did it with a
`required`, so the chart could not even render an identityhub without a plaintext token
in values - and anyone able to read ConfigMaps in the namespace could read the
credential that unlocks the participant's signing key. vault.hashicorp.existingSecret
now injects it as EDC_VAULT_HASHICORP_TOKEN instead, and the ConfigMap omits the
property entirely in that case rather than leaving an empty one behind for EDC to
resolve as an empty token.

The vault itself ran in dev mode, which is in-memory: every restart of that pod loses
the participant's signing key, the identityhub super-user credential, the STS client
secret and the transfer signing key fdsc-edc generates for itself, taking the DCP lane
down until the bootstrap Job runs again. Persistent storage alone does not fix it,
because a vault with storage comes back sealed and refuses every request. So
vault.production adds the missing half: initialise an empty vault once, store the unseal
keys in a Secret, and unseal whenever the vault is found sealed. It is a Deployment and
not a Job because the restart that needs it happens long after any deploy.

The trade-off is written into values.yaml rather than hidden: the unseal keys live in a
Kubernetes Secret, which is what makes an unattended restart recover on its own, and
autoUnseal: false leaves them out of the cluster at the cost of a manual unseal. The
Secret is annotated to survive uninstall, since deleting it while the storage volume
exists leaves a vault nobody can open. Production mode also refuses to render alongside
dev mode instead of silently doing nothing.

The unsealer runs under its own ServiceAccount with a Role scoped to the single Secret
it owns, so it cannot read the participant's identity key or its credentials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eval.sh renders every chart with default values only, and identityhub, vault, fdsc-edc
and vcCredentials are all disabled by default - so none of those templates was ever
reached by `helm template` or kubeconform. A broken manifest in that whole area passed
CI silently.

Each chart's ci/*-values.yaml is now rendered and validated as well. The first one turns
on the EDC lane, including the opt-in rotation Job and the branch that keeps the vault
token out of the ConfigMap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each of these cost time in a production deployment and none of them is visible from a
default render, which is why they survived: with identityhub, vault and fdsc-edc all
disabled by default, none of this code is exercised.

Dangling references, i.e. templates that named objects nothing created:

  * identityhub-custom-cacerts: declaring identityhub.customCaCerts mounted an empty
    emptyDir over the JVM truststore, replacing the bundled roots with nothing instead of
    extending them. Now a ConfigMap holds the extra CAs and an init container imports
    them into a writable copy of the truststore - which is what makes a vault or an issuer
    behind a private CA reachable at all.
  * identityhub-pvc-logs: enabling logging.persistence rendered a Deployment whose pod
    could never be scheduled.

The trusted-issuers registration was effectively install-only. It is annotated
post-install, so a release that already exists never runs it again, and the generated
script used `curl -f`, which turns the 409 of an already-registered issuer into a failed
Job - so making it run on upgrade would have failed every time. Both fixed: the script
now treats 409 as success and reports the status code, and the hook includes
post-upgrade. This is what kept a change to the credentials configuration from ever
reaching a running cluster.

Defaults that cannot work or should not be defaults:

  * fdsc-edc tmfExtension was enabled with seven empty addresses. TMFConfig converts them
    with URI.create(...).toURL() while the runtime boots, so the default configuration
    aborted startup with "URI is not absolute". Off by default now, and the k3s values
    that relied on the old default enable it explicitly.
  * oid4vp.trustAll defaulted to true - disabling TLS verification, and the only
    DO-NOT-USE-IN-PRODUCTION marker in the chart.
  * edc.log.level defaulted to DEBUG, which logs every DSP message including token
    contents.

Credential attributes in the realm:

  * vc.expiry_in_seconds and vc.refresh_interval_in_seconds are now kept in sync. The
    admin console shows the first while the issued `exp` comes from the second, so
    setting only one produced credentials that expired at a completely different time
    than the console claimed - with Keycloak's default, a week. Setting neither still
    leaves Keycloak's defaults alone.
  * vc.signing_key_id defaults to the key id the DID document actually publishes when the
    identityhub serves that document. Keycloak otherwise defaults it to the bare DID,
    which is not resolvable there, and issuance fails with "No key for id '<did>' and
    algorithm ... available".

Also: the accounts port is opened by the deployment but was missing from the service, so
the STS Accounts API was unreachable in-cluster.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…renewal

The chart pushed the credential into the identityhub's credential store once, from
the bootstrap Job, and nothing kept that copy in step afterwards. The vc-operator
renewed the Secret on its own schedule while the identityhub went on presenting
the previous credential until it expired, which took the DCP lane down with
nothing having been deployed and a Secret that looked healthy.

vcCredentials.requests[].identityHub.enabled hands that job to the vc-operator,
which already knows when a credential is renewed. Every address defaults to what
identityhub.bootstrap declares, so a participant provisioned by this chart needs
nothing beyond the toggle.

The api key has to come from a Secret, because that is what the operator reads;
rendering fails with an explanation rather than emitting a dangling reference when
only a plaintext identityhub.superuser.key is configured.

Requires the vc-operator release that understands spec.identityHub.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The chart's only unit tests covered OpenTelemetry tracing, so nothing asserted
anything about the templates this branch adds or repairs. 26 tests over two
suites, aimed at the things that were actually wrong in real deployments rather
than at rendering in general:

  * the bootstrap steps run in a guaranteed order, and the credential insert is a
    regular container so a missing credential fails the Job instead of looping in
    an init retry;
  * the hook includes post-upgrade, which is what a dev-mode vault requires;
  * the key id is derived from the DID and the alias in one place;
  * the rotation Job carries no Helm hook and revokes before adding, never
    calling the keypairs rotate endpoint - which appends a duplicate
    verificationMethod under the same id;
  * the vault token is in the ConfigMap by default, and omitted rather than
    emptied when it comes from a Secret;
  * a production vault refuses to render next to dev mode;
  * the unsealer's Role is scoped to the single Secret it owns;
  * the objects the deployment referenced while nothing created them - the
    truststore ConfigMap with its import step, and the logs claim - now exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DSP_INTEGRATION.md described the participant setup as a curl sequence to run from a
workstation, which is why every deployment reimplemented it. The automated path is
now the recommended one and the manual procedure stays as the local-deployment and
debugging path, with the two traps that are easy to miss when doing it by hand
written down: why the STS client secret is chosen rather than read from the
creation response, and why it is stored under two spellings of the alias.

A new production guide covers what a default install does not: a Vault that
survives a restart with its unseal trade-off stated rather than hidden, a non-root
token kept out of the ConfigMaps, the bootstrap values, keeping the identityhub's
credential copy in step, pinning cert-manager's rotationPolicy instead of relying
on a default upstream intends to change, and the runbook for rotating the signing
key - including the step people skip, restarting everything that read the key at
startup and the apisix that caches the JWKS under an unchanged kid.

Also noted where the manual insert is described: a credential put there by hand is
a copy that nothing keeps current, which is the failure that takes the DCP lane
down without any deployment having happened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The EDC values render the vc-operator's CredentialIssuer and
VerifiableCredentialRequest, whose CRDs are in no public catalog, and
kubeconform -strict treats an unknown kind as an error - so the validation pass
those values were added for would itself have failed CI. Everything with a
published schema is still validated strictly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vramperez
vramperez marked this pull request as draft August 17, 2026 11:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant