Rotación centralizada de la llave de firma del emisor
Fecha: 2026-07-23
Estado: Diseño aprobado, pendiente de revisión profunda antes de escribir el plan de implementación.
Contexto y problema
verifiably-go orquesta la emisión de credenciales a través de tres DPGs
(walt.id, Inji Certify, CREDEBL). Ninguno de los tres expone hoy un
mecanismo de rotación de la llave de firma del emisor diseñado desde Go —
cada uno la genera y gestiona a su manera, y verifiably-go no posee
ninguna de las tres llaves privadas hoy.
Existe ya un plan previo (memoria project-pluggable-signer,
verifiably-go/TODO.md §"PKI / HSM / KMS Integration") para introducir
internal/signer/ — una abstracción Provider sobre crypto.Signer con
backends intercambiables (pem, pkcs11, x509chain, remotekms). Ese
plan apuntaba originalmente solo a dos llaves auxiliares: el JWT del trust
registry (internal/trust/jwt.go) y la firma JWS de status lists
(internal/statuslist/jws.go).
Este documento extiende ese mismo internal/signer/ para cubrir una
tercera llave — la que firma las credenciales emitidas (SD-JWT VC) — y
define cómo se entrega/consume esa llave en cada uno de los tres DPGs,
dado que cada uno tiene un modelo de posesión de llave distinto.
Objetivo: un punto único en Go desde el cual se decide dónde y cómo
vive la llave del emisor (generada internamente, SoftHSM, CloudHSM/KMS) y
desde el cual se dispara la rotación — adaptándose al mecanismo real que
cada DPG expone, en vez de asumir un modelo uniforme.
No-objetivo de esta fase: migrar las llaves de trust-registry o
status-list (ya cubiertas por el plan existente); implementar los
backends pkcs11/awskms/azurekv reales (quedan como siguiente fase,
una vez la interfaz esté probada con el backend pem/jwk).
Hallazgos de investigación (por qué el diseño tiene esta forma)
Los tres DPGs no comparten modelo de llave
| DPG | Dónde vive la llave privada hoy | Método DID en este deploy |
|---|---|---|
| walt.id | issuer-api, generada vía POST /onboard/issuer (backend jwk por defecto) |
did:web (script bootstrap-waltid-did.sh), fallback did:jwk |
| Inji Certify | Postgres certify.key_store, protegida por MOSIP Key Manager sobre SoftHSM/PKCS12 (mosip.kernel.keymanager.hsm.*) |
did:web fijo por instancia (ISSUER_DID_DOMAIN) |
| CREDEBL | Agente Credo (agent-controller, sucesor activo de credo-controller), wallet Askar |
did:web (AGENT_DID_METHOD=did:web, AGENT_DID_DOMAIN) |
Los tres corren sobre did:web en este deploy — lo cual descarta la
fricción de "rotar implica una transacción on-ledger" (esa fricción solo
existiría si CREDEBL siguiera en did:indy/did:sov, o si cualquiera de
los tres usara did:key/did:jwk, donde el identificador es la llave y
rotar exige cambiar el DID mismo, no solo el material de firma).
Cada DPG expone un mecanismo distinto para traer/rotar la llave
-
walt.id es nativamente pluggable en su gestión de llaves: además del
backendjwk(generado internamente, usado hoy), soporta backends
externos —tse(HashiCorp Vault Transit),azure(Key Vault),oci
(OCI Vault) — donde walt.id nunca posee la llave privada, solo
recibe una referencia + credenciales de acceso al KMS en cada request
de emisión (issuerKey.type/server/auth/id). El campoides
literalmente unkidversionable.
Fuente:docs.walt.id/community-stack/issuer/api/manage-keys/*. -
Inji Certify no acepta una llave externa inyectada por request — el
HSM (SoftHSM o real) está detrás de su propio Key Manager
(kernel-keymanager-service), intercambiable por configuración de
Certify (hsm.keystore-type,hsm.config-path), no por Go. El único
control real desde fuera es orquestar el ciclo de vida vía
key_policy_def(expiry) o la API REST del Key Manager. Certify ya
tolera multi-kidde forma parcial:internal/injidid/observed.go+
InjiProxyPrimaryDidJSONsirven undid.jsoncon la unión de todos los
kids vistos firmando, precisamente porque Certify puede rotar sin
avisar. -
CREDEBL (
agent-controller, código verificado directamente —
src/controllers/did/DidController.ts::handleWeb) expone
POST /did/writeconmethod: "web", que acepta unseedprovisto por
el caller, deriva el JWK privado (transformPrivateKeyToPrivateJwk), lo
importa víaagent.kms.importKey, y llama
agent.dids.import({ did, overwrite: true, ... }). Llamar este mismo
endpoint de nuevo sobre el mismodid:web:{domain}con un seed distinto
rota la llave — confirmado en el código activo del repo
credebl/agent-controller(sucesor decredebl/credo-controller,
archivado 2026-01-21,size: 0). A diferencia de walt.id/tse, aquí el
material privado (seed) viaja por la red hacia el agente — trade-off
de seguridad explícito, no una limitación técnica.
El problema silencioso: snapshot estático del documento DID
Dos de los tres DPGs sirven el did.json como archivo estático que nadie
regenera automáticamente tras una rotación:
- walt.id:
bootstrap-waltid-did.shgenera el JSON una vez al deploy;
Caddy lo sirve verbatim (Caddyfile.public,respondestático). - CREDEBL:
_credebl_export_did_document(enbootstrap-credebl.sh)
haceGET /didsal agente y cachea el resultado en
.agent-runtime/did/did.json, servido por nginx
(credebl-oid4vci-rewriter) como archivo estático — no en vivo. - Inji Certify es la excepción — ya tolera multi-kid vía el merge
activo deinji_proxy.
Consecuencia de diseño: cualquier operación de rotación debe incluir,
como paso explícito, la republicación del documento DID — no basta con
rotar la llave en el DPG. Sin esto, los verificadores seguirían viendo la
llave vieja hasta el próximo restart/redeploy manual.
Arquitectura propuesta
internal/signer/ — extendido para exponer referencias, no solo firmar
// Provider ya existe en el plan previo (memoria project-pluggable-signer).
// Se le añade KeyRef() para que un backend pueda describirse a sí mismo
// sin exponer material privado — es lo que un DPG "pluggable" (walt.id)
// necesita para operar contra el mismo backend que Go.
type Provider interface {
crypto.Signer
Certificate() *x509.Certificate // nil si no aplica (no-PKI)
KeyRef() KeyReference
}
// KeyReference nunca contiene material privado. Describe cómo referenciar
// la llave activa ante un consumidor externo (un DPG adapter).
type KeyReference struct {
Backend string // "jwk" | "tse" | "azure" | "oci" | "pkcs11" | "seed-transfer"
Config map[string]any // específico del backend: server+auth (tse), vaultURL (azure)...
KeyID string // el "kid"/versión — pieza central de la rotación
}
Backends de fase 1: pem (ya existe, comportamiento actual) y jwk
(equivalente lógico, para hablar con walt.id backend jwk/CREDEBL
seed-transfer). pkcs11/tse/azure/oci quedan diseñados en la
interfaz pero implementados en una fase posterior, cuando haya un HSM o
KMS real contra el cual probarlos.
ProvisionIssuerKey — un método por adapter, tres implementaciones reales
// backend/adapter.go — extensión a la interfaz de adapter existente.
type KeyConsumer interface {
// SupportedKeyBackends declara qué backends de signer.Provider este
// DPG puede consumir. Si el backend configurado no está en la lista,
// el adapter cae a su modo nativo y lo loguea como degradación.
SupportedKeyBackends() []string
// ProvisionIssuerKey configura o rota la llave activa del emisor.
// Debe dejar la llave anterior resoluble (multi-kid) hasta que el
// llamador confirme que ya no hay credenciales pendientes de
// verificar contra ella.
ProvisionIssuerKey(ctx context.Context, ref signer.KeyReference) error
// PublishDIDDocument fuerza la republicación del did:web document
// tras una rotación. Ver "problema silencioso" arriba — sin esto la
// rotación es invisible para los verificadores.
PublishDIDDocument(ctx context.Context) error
}
Implementaciones:
-
internal/adapters/waltid:ProvisionIssuerKeyllama
POST /onboard/issuerconkey.backend = ref.Backendyref.Config
reenviado tal cual (Go nunca genera la llave si el backend estse—
solo la referencia).PublishDIDDocumentre-ejecuta la lógica de
bootstrap-waltid-did.sh(regenerar el JSON + recargar Caddy). -
internal/adapters/injicertify:ProvisionIssuerKeytraduce
refa una llamada a la API del Key Manager de Certify (o ajusta
key_policy_defsi no hay API directa disponible); si se pide un
backend que Certify no sabe hablar, retorna
signer.ErrBackendNotSupportedexplícito.PublishDIDDocumentes casi
un no-op — el merge dekids observados ya lo cubre, pero se expone
igual por consistencia de interfaz. -
internal/adapters/credebl:ProvisionIssuerKeyllama
POST /did/write(agent-controller) conmethod: "web",
overwrite: true, y elseedque Go entrega enref.Config["seed"]—
documentando explícitamente que el material privado transita por red
hacia el agente (a diferencia detseen walt.id).PublishDIDDocument
invoca el equivalente de_credebl_export_did_documentpara refrescar
eldid.jsoncacheado que sirve nginx.
Precondición transversal: tolerancia multi-kid
Antes de que cualquier rotación sea segura en producción, el sistema debe
tolerar múltiples kid/DID activos por emisor durante la ventana de
transición (credenciales ya emitidas con la llave vieja deben seguir
verificando). Esto ya es parcialmente cierto en Inji Certify
(internal/injidid/observed.go); no está garantizado hoy en el
verificador genérico ni en walt.id/CREDEBL. ProvisionIssuerKey no debe
invalidar la llave anterior — solo dejar de usarla para firmar credenciales
nuevas — y PublishDIDDocument debe publicar ambas mientras la ventana de
transición esté abierta.
Punto de entrada único
POST /admin/signer/rotate?dpg=waltid|injicertify|credebl
Resuelve el adapter configurado para ese DPG, genera/registra la nueva
llave en el backend de signer.Provider elegido, llama
ProvisionIssuerKey seguido de PublishDIDDocument, y deja un registro
auditable (qué kid se activó, cuándo, quién lo disparó).
Cuarto consumidor: mDL (mdoc / ISO 18013-5) — añadido 2026-08-21
Este diseño razona en términos de DIDs y kids, correcto para los tres
DPGs de arriba porque los tres corren did:web. mdoc no usa DIDs en
absoluto: la confianza va por cadena X.509 (IACA → DSC) que el
verificador valida contra un ancla instalada de antemano. Cuando este
spec pase a plan de implementación, debe absorber tres requisitos que hoy
no contempla:
-
Backend
x509chaincomo ciudadano de primera.KeyReference
describe dónde vive la llave; mdoc necesita además la cadena de
certificados que la acompaña.Provider.Certificate()ya está en la
interfaz y el plan previo ya nombrax509chain, pero ninguno está
desarrollado. UnProviderpara mdoc debe exponer la cadena completa
(DSC + IACA), no solo el certificado hoja. -
Distribución del ancla IACA — el análogo de
PublishDIDDocument.
Para mdoc, republicar undid.jsonno sirve de nada: lo que el
verificador necesita es el ancla IACA, y hoy se instala a mano
(confirmado en pruebas de esta sesión: hubo que importar el
certificado por la UI demultipaz-identity-readerpara que aceptara
la credencial). La Parte 4 de
docs/superpowers/adr/2026-08-20-mdl-production-path-analysis.mdya
analizó la forma correcta — una lista de anclas versionada y
firmada, replicando lo que el backend demultipaz-identity-reader
hace conGET /trusted-issuers?version=N— y descartó VICAL como
prematuro.KeyConsumernecesita un método equivalente para este
caso, oPublishDIDDocumentnecesita generalizarse a "publicar el
material de confianza que corresponda a este formato". -
La ventana multi-
kidtiene una restricción dura en mdoc. El
equivalente de tolerar doskids activos es la superposición de
validez de dos DSC. ISO/IEC 18013-5 Annex B lo acota: un DSC no puede
exceder 457 días de validez ni sobrevivir a la IACA que lo firma
(ambos límites ya implementados y con tests en
internal/mdl/pki/pki.go). La ventana de transición no es libre como
endid:web— está topada por la vigencia del certificado.
No bloquea al PR de mDL en curso. Ese PR usa una llave EC P-256
estable inyectada para issuer-api2, marcada explícitamente como
provisional, y no introduce mecanismo propio de claves — precisamente
para no crear un camino paralelo que luego haya que reconciliar con este
diseño.
Fuera de alcance de esta fase
- Backends reales
pkcs11/tse/azure/oci/awskms— la interfaz los
contempla, pero se implementan cuando haya infraestructura real contra
la cual probarlos. - Migración de las llaves de trust-registry/status-list al mismo
signer.Provider(ya cubierto por el plan existente, no repetido aquí). - Automatizar la ventana de expiración/revocación de la llave vieja tras
una rotación (por ahora es un paso manual/documentado, no un scheduler).
Riesgos y trade-offs a validar en la revisión profunda
- CREDEBL transfiere material privado por red en
POST /did/write
— es un trade-off de seguridad real frente al modelotsede walt.id,
no solo un detalle de implementación. - No está confirmado con un test real que Inji Certify exponga una
API de Key Manager operable desde fuera del contenedor — el diseño
asume que existe algo orquestable víakey_policy_defo REST, pero no
se validó contra una instancia corriendo. agent-controlleres un repo verificado por código fuente, no por
documentación oficial estable — al ser relativamente nuevo
(sucesor decredo-controller, archivado hace apenas medio año),
conviene revisar si hay cambios entre versiones que rompan el contrato
dehandleWeb.