Sistema de Identidad Descentralizada (DID) sin blockchain, compuesto por una wallet Android y un backend Java.
| Actor | Rol | DΓ³nde vive |
|---|---|---|
| Holder | DueΓ±o de la identidad. Genera su clave, solicita credenciales y las presenta. | App Android |
| Issuer | Emite credenciales verificables firmadas. Registra e invalida DIDs. | Backend Java |
| Verifier | Verifica presentaciones del holder: valida firmas, estado del DID y revocaciΓ³n de VCs. | Backend Java |
POC: Issuer y Verifier estΓ‘n implementados en el mismo backend, separados por endpoints distintos. En un sistema real serΓan organizaciones independientes.
El holder es el ΓΊnico custodio de sus credenciales. El issuer no las almacena.
Holder (app)
ββββββββββββββββββββββββββββββββββββββββββββββββββ
1. Genera par de claves secp256k1
2. Protege la clave privada en hardware del dispositivo
3. Deriva su DID:key a partir de la clave pΓΊblica
β did:key:zQ3s...
No requiere red. No requiere backend.
Antes de solicitar credenciales, el holder registra su DID junto a su identificador de cliente. Esto permite al issuer conocer quΓ© DIDs estΓ‘n activos y a quΓ© persona corresponden.
Holder (app) Issuer (backend)
βββββββββββββββββ ββββββββββββββββββββββββββββββββββ
POST /dids/register βββββββββββββββββββββββββββββ
{ client_id: "user@example.com",
did: "did:key:zQ3s..." }
Guarda { did, clientId, active: true }
βββββββββββββββ { did, client_id, active: true }
Si el holder pierde el dispositivo, el issuer puede invalidar ese DID con
POST /dids/{did}/invalidate. En el dispositivo nuevo se registra un DID nuevo bajo el mismoclient_id.
El DID debe estar registrado y activo para obtener un nonce.
Holder (app) Issuer (backend)
βββββββββββββββββ ββββββββββββββββββββββββββββββββββ
Tiene su propio DID e identidad
1. GET /credentials/nonce?holder_did=did:key:z... βββ
β DID registrado y activo
βββββββββββββββ { nonce }
2. Firma un Proof JWT con su clave privada
(incluye el nonce y los claims que quiere acreditar)
3. POST /credentials/issue ββββββββββββββββββββββ
{ holder_did, proof }
β DID activo
β Firma vΓ‘lida + nonce vΓ‘lido
Emite VC JWT firmada con su clave
Guarda solo metadatos (no el JWT)
βββββββββββββββ { credential: <VC JWT> }
4. Almacena la VC cifrada en el dispositivo
El holder construye una VP JWT (Verifiable Presentation) que empaqueta sus VCs y la firma con su clave privada. La envΓa al endpoint POST /credentials/verify del backend.
POC: el endpoint de verificaciΓ³n estΓ‘ en el mismo backend que el issuer. En producciΓ³n, el Verifier serΓa una organizaciΓ³n separada que consumirΓa los endpoints pΓΊblicos del issuer (
GET /dids/{did}yGET /credentials?holder_did=) para consultar estado y revocaciΓ³n.
Holder (app) Backend (Verifier + Issuer β mismo proceso)
βββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββ
1. Toma sus VCs almacenadas
2. Construye VP JWT:
{ iss: holderDid,
aud: "http://backend",
vp: { verifiableCredential: [VC_JWT] } }
3. La firma con su clave privada
4. POST /credentials/verify ββββββββββββββββββ
{ "vp_jwt": "eyJ..." }
β alg = ES256K
β VP no expirada
β Firma ES256K del holder vΓ‘lida
(clave pΓΊblica derivada del did:key del iss)
β DID del holder registrado y activo
Para cada VC dentro del VP:
β Firma ES256K del issuer vΓ‘lida
β iss de la VC = DID de este issuer
β sub de la VC = holderDid del VP
β VC no expirada
β VC no revocada (consulta interna al store)
ββββ 200 { "valid": true, "holder_did": "...",
"credentials": [{ credential_id, type, subject, ... }] }
Por quΓ© no se necesita red para verificar firmas: el did:key contiene la clave pΓΊblica β se deriva directamente del DID sin llamadas externas. Solo el estado dinΓ‘mico (DID activo, VC revocada) requiere consulta al store.
La clave privada nunca sale del dispositivo en claro. Se protege en dos capas:
Clave privada secp256k1
βββ cifrada con AES-256/GCM
βββ cuya clave vive en Android Keystore (hardware)
βββ StrongBox (chip dedicado) β si el dispositivo lo tiene
βββ TEE (zona aislada del SoC) β fallback en casi todos los Android modernos
La app detecta automΓ‘ticamente el nivel disponible y usa el mΓ‘s seguro.
Las VCs se almacenan cifradas con EncryptedSharedPreferences (AES-256/GCM). Incluso con acceso fΓsico al dispositivo o backup ADB, el contenido es ilegible.
El issuer no guarda el JWT de la VC. Lo entrega una sola vez al holder y conserva solo un hash para poder revocarla en el futuro. El holder es el ΓΊnico custodio.
Cada solicitud de credencial requiere un nonce fresco del issuer. El nonce se consume al verificarlo β enviarlo dos veces es rechazado.
El Proof JWT demuestra que el holder posee la clave privada correspondiente a su DID, sin revelarla. Es el mecanismo central de autenticaciΓ³n del protocolo.
clave privada (secp256k1, 32 bytes)
β
βΌ
clave pΓΊblica comprimida (33 bytes)
β
βΌ
multicodec prefix [0xe7, 0x01] + pub_bytes
β
βΌ
base58btc encode β z<encoded>
β
βΌ
DID = "did:key:z<encoded>"
El DID es la clave pΓΊblica codificada. No se registra en ningΓΊn servidor β se resuelve localmente. Quien recibe el DID puede derivar la clave pΓΊblica y verificar firmas sin contactar al issuer.
POST /dids/register
{ "client_id": "user@example.com", "did": "did:key:z..." }
β { "did": "...", "client_id": "...", "active": true }
El issuer solo entrega nonces a DIDs que conoce y estΓ‘n activos.
GET /credentials/nonce?holder_did=did:key:z...
β { "nonce": "abc123" }
El nonce es de un solo uso y tiene expiraciΓ³n de 5 minutos. Protege contra replay attacks: si alguien intercepta el Proof JWT, no puede reutilizarlo porque el nonce ya fue consumido.
header = { alg: ES256K, typ: openid4vci-proof+jwt, kid: "did:key:z...#z..." }
payload = {
iss: "did:key:z..." β quiΓ©n firma
aud: "https://issuer" β a quiΓ©n va dirigido
nonce: "abc123" β el nonce recibido
credential_type: "UniversityDegreeCredential"
subject_claims: { givenName, familyName, email }
}
firma = ECDSA(SHA256(header.payload), clave_privada)
La firma cubre el nonce β si alguien modifica el nonce, la firma es invΓ‘lida.
1. Extrae el DID del campo iss
2. Deriva la clave pΓΊblica del DID (es pΓΊblica, estΓ‘ en el DID)
3. Verifica la firma ECDSA con esa clave pΓΊblica
4. Verifica que el nonce coincida y no haya sido usado antes
5. Si todo ok β emite la VC firmada por el issuer
La clave privada nunca sale del dispositivo. El backend solo necesita la clave pΓΊblica (obtenida del DID) para verificar.
App Android / Postman Issuer Backend
βββββββββββββββββββββ ββββββββββββββββββββββββββββββ
1. ./scripts/gen-holder.sh
β genera clave privada + pΓΊblica
β deriva DID del holder
β guarda en .holder-keys
2. POST /dids/register βββββββββββββββββββββββββββββββββββββββββββββΆ
{ client_id: "user@example.com", did: "did:key:z..." }
guarda { did, clientId, active: true }
ββ { active: true } βββββββββββββββββββββββββββββββββββββββββββ
3. GET /credentials/nonce?holder_did=did:key:z... ββββββββββββββββββΆ
β DID registrado y activo
guarda nonce de un solo uso
ββ { nonce: "abc123" } ββββββββββββββββββββββββββββββββββββββββ
4. ./scripts/gen-proof.sh <nonce>
β lee clave privada de .holder-keys
β firma JWT con nonce + subject_claims
β imprime PROOF_JWT
5. POST /credentials/issue βββββββββββββββββββββββββββββββββββββββββΆ
{ holder_did, proof: PROOF_JWT }
β DID activo
β firma vΓ‘lida (clave pΓΊblica del DID)
β nonce consumido (one-time)
emite VC JWT firmada por el issuer
ββ { credential: "eyJ..." } βββββββββββββββββββββββββββββββββββ
El VP JWT (Verifiable Presentation) es el mecanismo con el que el holder demuestra ante el Verifier que posee una o mΓ‘s VCs. Lo construye Γ©l mismo, lo firma con su clave privada y lo envΓa al backend.
header = { alg: ES256K, typ: JWT, kid: "did:key:z...#z..." }
payload = {
iss: "did:key:z..." β DID del holder (quiΓ©n presenta)
aud: "https://backend" β a quiΓ©n va dirigido
iat: <unix timestamp>
exp: <unix timestamp + 300> β vΓ‘lido 5 minutos
vp: {
type: ["VerifiablePresentation"],
verifiableCredential: [ β lista de VCs JWT a presentar
"eyJ...VC_JWT..."
]
}
}
firma = ECDSA(SHA256(header.payload), clave_privada_del_holder)
La diferencia clave con el Proof JWT: no lleva nonce del issuer. La VP se construye de forma autΓ³noma β el holder decide cuΓ‘ndo y quΓ© presenta.
1. alg = ES256K
2. exp > ahora (presentaciΓ³n no expirada)
3. Derivar clave pΓΊblica del holder desde el campo iss (did:key)
4. Verificar firma ES256K sobre header.payload
5. DID del holder estΓ‘ registrado y activo (consulta al store)
Por cada VC en vp.verifiableCredential:
6. Verificar firma ES256K del issuer (clave pΓΊblica derivada del iss de la VC)
7. iss de la VC == DID de este issuer (rechaza VCs de issuers desconocidos)
8. sub de la VC == iss del VP (la VC le pertenece al holder que presenta)
9. exp de la VC > ahora
10. VC no revocada (consulta al store por su jti)
App Android / Terminal Backend (Verifier)
ββββββββββββββββββββββ ββββββββββββββββββββββββββββββ
(ya tienes .holder-keys y VC_JWT)
./scripts/gen-vp.sh <VC_JWT>
β lee clave privada de .holder-keys
β construye VP JWT con la VC
β firma con ES256K
β POST /credentials/verify βββββββββββββββββββββββββββββββββββββΆ
β firma del holder
β DID activo
β firma del issuer en la VC
β VC no revocada
βββ { valid: true, holder_did, credentials: [...] } βββββββββ
# Paso 1 β generar identidad del holder (una sola vez)
./scripts/gen-holder.sh
# β imprime HOLDER_DID, guarda .holder-keys
# β copiar HOLDER_DID a la variable HOLDER_DID de la colecciΓ³n
# Paso 2 β registrar DID en el issuer (ejecutar "POST Register DID" en Postman)
# β body: { "client_id": "user@example.com", "did": "{{HOLDER_DID}}" }
# Paso 3 β obtener nonce (ejecutar "GET Nonce" en Postman)
# β NONCE se guarda automΓ‘ticamente en la variable de colecciΓ³n
# Paso 4 β generar proof con el nonce
./scripts/gen-proof.sh <NONCE>
# β imprime PROOF_JWT
# β copiar a la variable PROOF_JWT de la colecciΓ³n
# Paso 5 β ejecutar "POST Issue VC" en Postman
# β guarda el valor de "credential" (VC_JWT)
# Paso 6 β generar y enviar la VP (ejecuta curl automΓ‘ticamente)
./scripts/gen-vp.sh <VC_JWT>
# β imprime VP_JWT y resultado del backend (valid: true/false)
# β para probarlo en Postman: copiar VP_JWT a "POST Verify VP"Ver README-postman.md para la guΓa completa paso a paso.
{
"iss": "did:key:zQ3s...",
"aud": "http://issuer.example.com",
"nonce": "abc123",
"credential_type": "UniversityDegreeCredential",
"subject_claims": { "givenName": "Juan", "familyName": "PΓ©rez" }
}Firmado con la clave privada del holder (ES256K).
{
"iss": "did:key:zIssuer...",
"sub": "did:key:zQ3s...",
"vc": {
"type": ["VerifiableCredential", "UniversityDegreeCredential"],
"credentialSubject": { "id": "did:key:zQ3s...", "givenName": "Juan" }
}
}Firmado con la clave privada del issuer (ES256K).
{
"iss": "did:key:zQ3s...",
"aud": "https://backend.example.com",
"iat": 1772674193,
"exp": 1772674493,
"vp": {
"type": ["VerifiablePresentation"],
"verifiableCredential": ["<VC_JWT>"]
}
}Firmado con la clave privada del holder (ES256K). VΓ‘lido 5 minutos. Sin nonce β el holder lo construye de forma autΓ³noma.
| EstΓ‘ndar | Para quΓ© se usa |
|---|---|
| W3C DID Core 1.0 | Formato y resoluciΓ³n de DIDs |
| did:key | MΓ©todo DID autΓ³nomo β el DID se deriva directamente de la clave pΓΊblica, sin registro externo |
| W3C VC Data Model 1.1 | Estructura de las Verifiable Credentials y Presentations |
| JWT VC/VP | Encoding de VCs y VPs como tokens JWT compactos |
| ES256K β RFC 8812 | Algoritmo de firma: ECDSA con curva secp256k1 y hash SHA-256 |
| OpenID4VCI (draft) | Protocolo de solicitud de credencial entre holder e issuer |
| Clave | Tipo | QuiΓ©n la tiene | Para quΓ© se usa |
|---|---|---|---|
| Clave privada del holder | secp256k1 (256 bits) | Solo el dispositivo Android | Firmar el Proof JWT y las VPs. Nunca sale del dispositivo. |
| Clave pΓΊblica del holder | secp256k1 comprimida (33 bytes) | Embebida en el DID:key | Permite que cualquiera verifique sus firmas sin contactar al issuer. |
| Clave privada del issuer | secp256k1 (256 bits) | Solo el backend | Firmar las VCs que emite. Se genera una vez y se persiste. |
| Clave pΓΊblica del issuer | secp256k1 comprimida (33 bytes) | Su DID Document | Permite que el verifier valide las VCs sin contactar al issuer. |
| Clave AES-256 (wrapping) | AES-256 simΓ©trica | Android Keystore (hardware) | Cifrar la clave privada del holder en disco. Nunca sale del chip. |
La clave AES no tiene rol criptogrΓ‘fico en el protocolo DID β su ΓΊnico trabajo es proteger la clave secp256k1 mientras estΓ‘ almacenada en el dispositivo.
- Un par de claves secp256k1 β se genera automΓ‘ticamente al primer inicio
- Un DID propio β derivado de su clave pΓΊblica (
did:key) - Endpoint de nonce β protege contra replay attacks
- Endpoint de emisiΓ³n β verifica el proof y firma la VC
- Registro de DIDs β saber quΓ© DIDs estΓ‘n activos y a quΓ© cliente pertenecen
- Persistencia β solo metadatos de auditorΓa (H2 en local, Azure Table Storage en cloud)
No se necesita blockchain, PKI propia ni registro DID externo. did:key se resuelve localmente a partir del propio DID.
| MΓ©todo | URL | DescripciΓ³n |
|---|---|---|
POST |
/dids/register |
Registra un DID asociado a un client_id |
GET |
/dids/{did} |
Estado del DID (activo/inactivo) β para verificadores |
POST |
/dids/{did}/invalidate |
Invalida un DID (dispositivo perdido o app borrada) |
GET |
/clients/{clientId}/dids |
Lista todos los DIDs de un cliente |
| MΓ©todo | URL | DescripciΓ³n |
|---|---|---|
GET |
/credentials/nonce?holder_did= |
Nonce de un solo uso (DID debe estar activo) |
POST |
/credentials/issue |
Recibe proof JWT, emite VC JWT |
POST |
/credentials/verify |
Verifica una VP JWT (firma, DID activo, VCs no revocadas) |
GET |
/credentials?holder_did= |
Metadatos de VCs emitidas (sin contenido) |
POST |
/credentials/{credentialId}/revoke |
Revoca una VC por su ID |
| MΓ©todo | URL | DescripciΓ³n |
|---|---|---|
GET |
/issuer/did |
DID e info del issuer |
GET |
/issuer/did-document |
DID Document del issuer |
didbmo/
βββ android/app/src/main/java/com/did/wallet/
β βββ security/KeyManager.java β claves secp256k1 + Android Keystore
β βββ did/DIDManager.java β construcciΓ³n del DID:key
β βββ credential/
β β βββ CredentialRequestBuilder.java β proof JWT firmado
β β βββ CredentialService.java β HTTP al issuer + almacenamiento cifrado
β βββ presentation/VPBuilder.java β VP JWT firmada
β βββ ui/MainActivity.java β demo de los 3 flujos
β
βββ backend/src/main/java/com/did/issuer/
βββ config/IssuerKeyConfig.java β clave e identidad del issuer
βββ controller/
β βββ CredentialController.java β nonce, issue, revoke, metadatos
β βββ HolderDIDController.java β register, invalidate, status
βββ service/
β βββ NonceService.java β nonces single-use
β βββ ProofVerifier.java β verifica proof JWT
β βββ CredentialIssuerService.java β emite y firma VCs
β βββ HolderDIDService.java β registro e invalidaciΓ³n de DIDs
βββ store/
β βββ CredentialStore.java β interface
β βββ JpaCredentialStore.java β H2 (local)
β βββ AzureTableCredentialStore.javaβ Azure Table Storage
β βββ HolderDIDStore.java β interface
β βββ JpaHolderDIDStore.java β H2 (local)
β βββ AzureTableHolderDIDStore.java β Azure Table Storage
βββ model/
βββ CredentialRecord.java β metadatos de VC emitida
βββ HolderDIDRecord.java β registro de DID del holder