Esta guía explica la lógica que la app Android debe implementar en relación al protocolo DID/VC: cómo generar claves, derivar el DID, armar y firmar el Proof JWT, y verificar la VC que devuelve el issuer. Los scripts gen-holder.sh y gen-proof.sh son una reimplementación Python del mismo proceso, útil para pruebas desde terminal o Postman.
- Curva secp256k1 — el punto de partida
- Generación del par de claves
- Compresión de la clave pública
- Derivación del DID
- Proof JWT y VC JWT — estructura, firma y validación
- VP JWT — presentación de credenciales
- Verificación de la VC al recibirla
- Protección de la clave privada en Android
- Equivalencia Android ↔ Python
Toda la criptografía se basa en la curva elíptica secp256k1 — la misma que usa Bitcoin y Ethereum.
Una curva elíptica es un conjunto de puntos que satisfacen la ecuación:
y² = x³ + 7 (mod p)
donde p es un número primo de 256 bits definido en el estándar secp256k1.
Por qué secp256k1 y no RSA o AES:
- Las claves son pequeñas (32 bytes privada, 33 bytes pública comprimida)
- La firma es compacta (64 bytes)
- Es el estándar de facto en el ecosistema DID/VC
- RFC 8812 la formaliza como
ES256Kpara uso en JWT
- Clave privada: un número entero aleatorio de 256 bits (32 bytes). Solo existe en el dispositivo.
- Clave pública: un punto (x, y) en la curva, derivado de la privada. Es pública — se puede compartir libremente.
La relación es unidireccional: de la privada se deriva la pública, pero no al revés (problema del logaritmo discreto).
// Genera un par secp256k1 usando BouncyCastle
ECNamedCurveParameterSpec spec = ECNamedCurveTable.getParameterSpec("secp256k1");
KeyPairGenerator kpg = KeyPairGenerator.getInstance("ECDSA", "BC");
kpg.initialize(spec, new SecureRandom());
KeyPair pair = kpg.generateKeyPair();
ECPrivateKey priv = (ECPrivateKey) pair.getPrivate();
ECPublicKey pub = (ECPublicKey) pair.getPublic();
// Clave privada: el escalar S (entero de 256 bits)
byte[] privBytes = to32Bytes(priv.getS());
// Clave pública: el punto (x, y) comprimido a 33 bytes
byte[] pubBytes = compressedPublicKey(pub);from cryptography.hazmat.primitives.asymmetric.ec import generate_private_key, SECP256K1
from cryptography.hazmat.backends import default_backend
priv_key = generate_private_key(SECP256K1(), default_backend())
pub_key = priv_key.public_key()La clave pública es simplemente un punto en la curva, definido por dos coordenadas: (x, y). Cada coordenada ocupa 32 bytes, así que en su forma completa la clave pública ocupa 64 bytes.
El problema es que esos 64 bytes tienen que viajar dentro del DID — que es un string que la app muestra, copia, comparte y manda en requests HTTP. Cuanto más corto, mejor.
La compresión reduce la clave pública de 64 a 33 bytes, sin perder información. Esos 33 bytes son lo que se usa para construir el DID y para incluir en el kid del JWT.
Dado un valor de x, la curva solo puede producir dos posibles valores de y. Los dos siempre tienen paridades opuestas: uno es par y el otro es impar.
Entonces en vez de guardar (x, y) completo, alcanza con guardar:
[prefijo] [x — 32 bytes]
1 byte 32 bytes
─────────────────────
33 bytes total
prefijo 0x02 → el y que le corresponde es par
prefijo 0x03 → el y que le corresponde es impar
Cualquier librería criptográfica puede reconstruir y a partir de x y el prefijo cuando lo necesite. La app nunca tiene que hacer eso manualmente — solo trabaja con los 33 bytes comprimidos.
En Android, la compresión se hace una sola vez al generar las claves y el resultado se guarda en SharedPreferences como hex. Después, DIDManager lo lee directamente para construir el DID.
// KeyManager.java — al generar el par de claves
byte[] pubBytes = compressedPublicKey(pub); // → 33 bytes
prefs().edit().putString("public_key_hex", hex(pubBytes)).apply();// DIDManager.java — para construir el DID usa esos 33 bytes directamente
byte[] pubKey = KeyManager.unhex(keyManager.getPublicKeyHex()); // 33 bytesEn Python hace lo mismo en una línea:
pub_bytes = pub_key.public_bytes(Encoding.X962, PublicFormat.CompressedPoint)
# → 33 bytes: [0x02 o 0x03] + xEl DID se deriva determinísticamente de la clave pública comprimida. No hay registro externo — el DID se puede recalcular siempre que tengas la clave pública.
1. Tomar los 33 bytes de la clave pública comprimida
2. Prepender el identificador multicodec de secp256k1-pub:
bytes [0xe7, 0x01] ← varint encoding del código 0xe7 (secp256k1-pub)
resultado: 35 bytes
3. Codificar en Base58btc (alfabeto Bitcoin)
resultado: string de ~48 caracteres
4. Prepender 'z' (prefijo multibase para base58btc)
resultado: "z" + base58btc(...)
5. Construir el DID:
"did:key:" + resultado
private static final byte[] SECP256K1_MULTICODEC = {(byte) 0xe7, (byte) 0x01};
public String getDID() {
byte[] pubKey = KeyManager.unhex(keyManager.getPublicKeyHex());
byte[] multikey = concat(SECP256K1_MULTICODEC, pubKey); // [0xe7, 0x01] + pubKey (35 bytes)
String encoded = "z" + Base58.encode(multikey); // multibase base58btc
return "did:key:" + encoded;
}ALPHABET = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
def b58encode(data: bytes) -> str:
n = int.from_bytes(data, "big")
result = []
while n > 0:
n, r = divmod(n, 58)
result.append(ALPHABET[r:r+1])
leading = len(data) - len(data.lstrip(b"\x00"))
return (ALPHABET[0:1] * leading + b"".join(reversed(result))).decode()
multicodec = bytes([0xe7, 0x01]) + pub_bytes # mismo que Java
encoded = "z" + b58encode(multicodec)
holder_did = "did:key:" + encodedBase58 es Base64 sin los caracteres visualmente ambiguos: 0 (cero), O (letra O), I (letra I), l (letra l), y sin + ni /. El resultado es más fácil de copiar y comparar visualmente.
Para el header del JWT se necesita el kid, que identifica qué clave dentro del DID se usó para firmar:
did:key:zQ3sh... → kid = did:key:zQ3sh...#zQ3sh...
↑
fragment = la parte codificada del DID
// DIDManager.java
public String getKeyId(String did) {
String fragment = did.substring("did:key:".length()); // quita "did:key:"
return did + "#" + fragment;
}| Estándar | Qué define |
|---|---|
| RFC 7519 — JWT | El formato compacto header.payload.signature en base64url |
| RFC 8812 — ES256K | El uso de ECDSA secp256k1 + SHA-256 como algoritmo de firma en JWT |
| RFC 6979 | Generación determinística del nonce interno de firma (evita vulnerabilidades por aleatoriedad) |
| OpenID4VCI (draft) | El tipo openid4vci-proof+jwt y los campos del payload del Proof JWT |
| W3C VC Data Model 1.1 | La estructura del objeto vc dentro del payload de la VC JWT |
| W3C DID Core 1.0 | El formato del DID y cómo se referencia la clave en el kid |
base64url(header_json) . base64url(payload_json) . base64url(firma)
────────────────────── ──────────────────────── ──────────────────
HEADER PAYLOAD SIGNATURE
La firma cubre solo los dos primeros segmentos concatenados con punto. El tercer segmento es la firma de esos dos.
Lo genera el holder y lo envía al issuer como prueba de que controla su clave privada.
Header (CredentialRequestBuilder.java / gen-proof.sh):
{
"alg": "ES256K",
"typ": "openid4vci-proof+jwt",
"kid": "did:key:zQ3sh...#zQ3sh..."
}| Campo | Valor | Propósito |
|---|---|---|
alg |
ES256K |
Indica con qué algoritmo se firmó. El backend rechaza si no es ES256K. |
typ |
openid4vci-proof+jwt |
Identifica el propósito del token. Evita que un JWT de otro uso sea aceptado aquí. |
kid |
did#fragment |
Apunta a la clave pública del holder. El backend la usa para verificar la firma. |
Payload (CredentialRequestBuilder.java / gen-proof.sh):
{
"iss": "did:key:zQ3sh...",
"aud": "https://issuer.example.com",
"iat": 1772674193,
"exp": 1772674493,
"nonce": "abc123...",
"credential_type": "UniversityDegreeCredential",
"subject_claims": {
"givenName": "Juan",
"familyName": "Pérez",
"email": "juan@example.com"
}
}| Campo | Quién lo pone | Propósito |
|---|---|---|
iss |
Holder (su DID) | Identifica quién firma. De aquí se extrae la clave pública para verificar. |
aud |
Holder (URL del issuer) | Ata el JWT a un destinatario. Otro issuer no puede reutilizarlo. |
iat / exp |
Holder (timestamps actuales) | Ventana de validez de 5 minutos. Un JWT interceptado después expira. |
nonce |
Holder (lo recibió del backend) | Valor único por solicitud. El backend lo verifica y lo consume. Impide replay. |
credential_type |
Holder | Qué tipo de credencial pide. |
subject_claims |
Holder | Los datos que quiere que figuren en la VC. El holder decide qué declara. |
Con la clave privada secp256k1 del holder (JWTUtil.java):
signing_input = base64url(header) + "." + base64url(payload)
firma = ES256K(signing_input, clave_privada_holder)
proof_jwt = signing_input + "." + base64url(firma)
La clave privada del holder nunca sale del dispositivo. Solo la firma viaja en el JWT.
ProofVerifier.java — en orden:
1. Verificar que tiene 3 partes separadas por "."
2. alg == "ES256K" → si no, rechazar
3. iss == holder_did enviado en el body → si no, rechazar
4. exp > ahora → si expiró, rechazar
5. nonce existe en el registro del backend → si no, rechazar
y no fue usado antes → si ya se usó, rechazar
→ marcar como consumido
6. Extraer clave pública del holderDid:
did:key:z<encoded> → base58decode → quitar [0xe7,0x01] → 33 bytes
7. Verificar la firma ES256K con esa clave pública
→ si inválida, rechazar
8. Si todo ok → pasar subject_claims al servicio de emisión
El backend nunca necesita la clave privada del holder — solo la clave pública, que está embebida en el DID.
Lo genera el issuer y lo entrega al holder. Es la credencial verificable.
Header (CredentialIssuerService.java):
{
"alg": "ES256K",
"typ": "JWT",
"kid": "did:key:zIssuer...#zIssuer..."
}Aquí el kid apunta a la clave del issuer, no del holder.
Payload (CredentialIssuerService.java):
{
"jti": "urn:uuid:550e8400-e29b-41d4-a716-446655440000",
"iss": "did:key:zIssuer...",
"sub": "did:key:zHolder...",
"iat": 1772674193,
"exp": 1772760593,
"vc": {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"id": "urn:uuid:550e8400-...",
"type": ["VerifiableCredential", "UniversityDegreeCredential"],
"issuer": "did:key:zIssuer...",
"issuanceDate": "2026-03-05T10:00:00Z",
"expirationDate": "2027-03-05T10:00:00Z",
"credentialSubject": {
"id": "did:key:zHolder...",
"givenName": "Juan",
"familyName": "Pérez",
"email": "juan@example.com"
}
}
}| Campo | Quién lo pone | Origen del dato | Propósito |
|---|---|---|---|
jti |
Issuer | UUID generado | ID único de la VC. Permite revocarla. |
iss |
Issuer | Su propio DID | Identifica quién emitió la VC. El verifier lo usa para verificar la firma. |
sub |
Issuer | holder_did del request |
A quién pertenece la VC. |
iat / exp |
Issuer | Configurable (vc-ttl-seconds, default 24h) |
Vigencia de la credencial. |
vc.credentialSubject |
Issuer | subject_claims del Proof JWT |
Los datos del holder que el issuer certifica. |
El issuer toma los subject_claims del Proof JWT (que el holder declaró) y los certifica firmándolos con su propia clave.
Con la clave privada secp256k1 del issuer (CredentialIssuerService.java):
signing_input = base64url(header) + "." + base64url(payload)
firma = ES256K(signing_input, clave_privada_issuer)
vc_jwt = signing_input + "." + base64url(firma)
La clave privada del issuer vive en Azure Key Vault y se carga al arrancar el servicio.
Guarda → hash SHA-256 del VC JWT (para poder revocar)
→ metadatos: holderDid, credentialType, issuedAt, expiresAt, revoked
No guarda → el JWT completo
El VC JWT se entrega una sola vez al holder. El holder es el único custodio. Si lo pierde, no hay forma de recuperarlo — habría que emitir uno nuevo.
Un verifier externo que recibe el VC JWT de un holder:
1. Separar las 3 partes del JWT
2. Decodificar el header → extraer kid (DID del issuer)
3. Extraer clave pública del issuer desde su DID:
did:key:z<encoded> → base58decode → quitar [0xe7,0x01] → 33 bytes
4. Verificar la firma ES256K con esa clave pública
5. Verificar exp > ahora
6. Verificar que sub (holderDid) coincide con quien presenta la VC
7. Opcional: consultar /credentials?holder_did= para verificar que no está revocada
El verifier no necesita contactar al issuer para verificar la firma — la clave pública del issuer se puede resolver directamente desde su DID.
Lo genera el holder para presentar una o más VCs ante el Verifier. Es el mecanismo con el que demuestra que posee esas credenciales sin revelar su clave privada.
El VC JWT acredita al holder (lo emite el issuer). Pero para enviarlo a un verifier, el holder necesita demostrarse a sí mismo como el titular — para eso lo envuelve en una VP JWT que firma con su propia clave.
Diferencia con el Proof JWT: la VP no necesita nonce del issuer. El holder la construye de forma autónoma, decide qué VCs incluye y decide cuándo la envía.
Header (VPBuilder.java / gen-vp.sh):
{
"alg": "ES256K",
"typ": "JWT",
"kid": "did:key:zQ3sh...#zQ3sh..."
}| Campo | Valor | Propósito |
|---|---|---|
alg |
ES256K |
Algoritmo de firma. El backend rechaza si no es ES256K. |
typ |
JWT |
Tipo de token estándar. |
kid |
did#fragment |
Apunta a la clave pública del holder para verificar la firma. |
Payload (VPBuilder.java / gen-vp.sh):
{
"iss": "did:key:zQ3sh...",
"aud": "https://backend.example.com",
"iat": 1772674193,
"exp": 1772674493,
"vp": {
"type": ["VerifiablePresentation"],
"verifiableCredential": [
"eyJhbGci...VC_JWT_1...",
"eyJhbGci...VC_JWT_2..."
]
}
}| Campo | Quién lo pone | Propósito |
|---|---|---|
iss |
Holder (su DID) | Identifica quién presenta. Se usa para derivar la clave pública y verificar la firma. |
aud |
Holder (URL del verifier) | Ata la VP a un destinatario específico. Otro verifier no puede reutilizarla. |
iat / exp |
Holder | Ventana de validez de 5 minutos. Una VP interceptada después expira. |
vp.verifiableCredential |
Holder | Array de VC JWTs a presentar. El holder elige cuáles incluir. |
Con la clave privada secp256k1 del holder (VPBuilder.java / gen-vp.sh):
signing_input = base64url(header) + "." + base64url(payload)
firma = ES256K(signing_input, clave_privada_holder)
vp_jwt = signing_input + "." + base64url(firma)
La clave privada nunca sale del dispositivo. Las VCs en el interior de la VP están firmadas por el issuer — el holder no puede modificarlas sin invalidar esas firmas.
VPVerifierService.java — en orden:
1. Verificar que tiene 3 partes separadas por "."
2. alg == "ES256K" → si no, rechazar
3. exp > ahora → si expiró, rechazar
4. Extraer clave pública del holder desde iss:
did:key:z<encoded> → base58decode → quitar [0xe7,0x01] → 33 bytes
5. Verificar la firma ES256K del VP con esa clave pública
6. DID del holder registrado y activo (consulta al store)
Por cada VC en vp.verifiableCredential:
7. iss de la VC == DID de este issuer → si no, rechazar
8. Extraer clave pública del issuer desde su DID
9. Verificar la firma ES256K de la VC con esa clave pública
10. sub de la VC == iss del VP (el holder es el titular)
11. exp de la VC > ahora
12. VC no revocada (consulta al store por su jti)
Respuesta si todo es válido:
{
"valid": true,
"holder_did": "did:key:zQ3sh...",
"credentials": [
{
"credential_id": "urn:uuid:...",
"type": "UniversityDegreeCredential",
"subject": { "givenName": "Juan", "familyName": "Pérez", "email": "juan@example.com" },
"expires_at": "2027-..."
}
]
}now = int(time.time())
header_json = json.dumps({"alg": "ES256K", "typ": "JWT", "kid": kid})
payload_json = json.dumps({
"iss": holder_did,
"aud": base_url,
"iat": now,
"exp": now + 300,
"vp": {
"type": ["VerifiablePresentation"],
"verifiableCredential": [vc_jwt]
}
})
signing_input = b64url(header_json) + "." + b64url(payload_json)
der_sig = priv_key.sign(signing_input.encode(), ECDSA(hashes.SHA256()))
r, s = decode_dss_signature(der_sig)
sig_bytes = r.to_bytes(32, "big") + s.to_bytes(32, "big")
vp_jwt = signing_input + "." + b64url(sig_bytes)# Ya tienes .holder-keys (de gen-holder.sh) y VC_JWT (de la emisión)
./scripts/gen-vp.sh <VC_JWT>
# Contra Azure
./scripts/gen-vp.sh <VC_JWT> https://did-issuer-app.azurecontainerapps.ioHOLDER ISSUER / VERIFIER (mismo backend en POC)
────── ─────────────────────────────────────────
── Registro ────────────────────────────────────────────────────────────
POST /dids/register ────────────▶
{ client_id, did }
guarda { did, clientId, active: true }
◀─ { active: true } ────────────
── Emisión de VC ───────────────────────────────────────────────────────
GET /credentials/nonce ─────────▶
✓ DID registrado y activo
genera nonce (TTL 5 min, one-time)
◀─ { nonce } ───────────────────
Arma Proof JWT:
header { alg: ES256K,
typ: openid4vci-proof+jwt, kid }
payload { iss=holderDID, aud=URL,
nonce, subject_claims }
firma con clave privada
POST /credentials/issue ────────▶
{ holder_did, proof: Proof JWT }
✓ alg, iss, exp, nonce, firma
consume el nonce
arma VC JWT firmada con clave del issuer
guarda solo hash + metadatos
◀─ { credential: VC_JWT } ──────
Guarda VC_JWT en dispositivo
(cifrado, nunca en claro)
── Presentación ────────────────────────────────────────────────────────
Arma VP JWT:
header { alg: ES256K, typ: JWT, kid }
payload { iss=holderDID, aud=URL,
exp=now+300,
vp { verifiableCredential: [VC_JWT] } }
firma con clave privada
POST /credentials/verify ───────▶
{ vp_jwt: VP JWT }
✓ alg, exp, firma del holder
✓ DID del holder activo
✓ firma del issuer en cada VC
✓ sub de VC == holder, exp > now
✓ VC no revocada
◀─ { valid: true, credentials } ─
| Proof JWT | VC JWT | VP JWT | |
|---|---|---|---|
| Lo genera | Holder | Issuer | Holder |
| Lo firma | Clave privada del holder | Clave privada del issuer | Clave privada del holder |
| Se verifica con | Clave pública del holder (del DID) | Clave pública del issuer (del DID) | Clave pública del holder (del DID) |
iss |
DID del holder | DID del issuer | DID del holder |
aud / sub |
aud = URL del issuer |
sub = DID del holder |
aud = URL del verifier |
typ |
openid4vci-proof+jwt |
JWT |
JWT |
| Vida útil | 5 minutos | 24 horas (configurable) | 5 minutos |
| Propósito | Demostrar posesión de clave para pedir una VC | Credencial verificable emitida por el issuer | Presentar una o más VCs ante un verifier |
| Nonce | Sí (del issuer, anti-replay) | No | No |
| Contiene VCs | No | N/A (es la VC) | Sí (vp.verifiableCredential[]) |
| Se guarda | No (descartable tras verificar) | Sí, en el dispositivo del holder | No (efímera, se crea al momento de presentar) |
| Script de ejemplo | gen-proof.sh |
— | gen-vp.sh |
Cuando el holder recibe el VC JWT del issuer, debe verificarlo antes de guardarlo. Actualmente CredentialService.java lo guarda directamente sin validar — esta sección describe la lógica que falta implementar.
La app configura un ISSUER_URL al arrancar. Pero sin verificar la firma, cualquier servidor que responda en esa URL podría devolver un JWT firmado con su propia clave y la app lo guardaría como legítimo. La verificación criptográfica es la garantía que no depende de la red.
JWT recibido: eyJhbGci... . eyJpc3Mi... . firma
header payload
1. Estructura válida
El JWT debe tener exactamente 3 partes separadas por ..
2. Algoritmo esperado
header.alg == "ES256K"
Si el issuer devuelve un algoritmo distinto, rechazar. Evita ataques de confusión de algoritmo (ej. alg: none).
3. La VC es para este holder
payload.sub == mi propio holderDid
Garantiza que no se está guardando accidentalmente la credencial de otro usuario.
4. La VC no está expirada
payload.exp > ahora
No tiene sentido guardar una VC que ya venció al recibirla.
5. El issuer es el esperado
payload.iss == issuerDid conocido por la app
Este es el campo más crítico junto con la firma. El issuerDid puede obtenerse de dos formas:
- Hardcodeado como constante en la app (más simple, válido para un issuer fijo)
- Consultado al arrancar con
GET /issuer/didy guardado en memoria
6. La firma es válida
Verificar que el JWT fue firmado por la clave privada correspondiente al iss.
Como el issuer usa did:key, la clave pública está embebida en el propio DID — no hace falta llamar a ningún endpoint para resolverla:
payload.iss = "did:key:zIssuer..."
↓
quitar prefijo "did:key:"
↓
quitar prefijo multibase "z"
↓
base58btc decode
↓
quitar primeros 2 bytes [0xe7, 0x01]
↓
33 bytes = clave pública comprimida del issuer
↓
verificar firma ES256K del JWT con esa clave pública
La misma lógica que usa el backend para resolver el DID del holder aplica acá para resolver el DID del issuer. El código ya existe en el backend en DIDKeyUtil.publicKeyFromDIDKey() — el dev mobile necesita el equivalente en Java/Android con BouncyCastle (que ya es una dependencia del proyecto).
POST /credentials/issue
↓
recibir { credential: "eyJ..." }
↓
┌─ verificar ────────────────────────────────────┐
│ 1. estructura: 3 partes │
│ 2. header.alg == "ES256K" │
│ 3. payload.sub == holderDid │
│ 4. payload.exp > ahora │
│ 5. payload.iss == issuerDid esperado │
│ 6. firma válida (clave pública extraída del iss)│
└────────────────────────────────────────────────┘
↓ ok ↓ falla
storeCredential lanzar excepción
CredentialService.java línea 64 hace directamente:
storeCredential(vcJwt); // ← falta la verificación antes de esta líneaLa verificación de firma requiere agregar un método equivalente a DIDKeyUtil.verifyES256K() accesible desde Android, usando BouncyCastle que ya está en el proyecto como dependencia de KeyManager.java.
Esta parte no existe en los scripts Python — es exclusiva del dispositivo Android. Es la capa que hace que la clave privada nunca salga del chip de seguridad.
Clave privada secp256k1 (32 bytes)
│
│ cifrada con AES-256/GCM
▼
Clave privada cifrada → guardada en SharedPreferences
↑
│ la clave AES vive en:
│
├── StrongBox (API 28+) ← chip dedicado (Titan M, Pixel 3+)
│ Mayor aislamiento: opera independientemente del SoC principal
│
├── TEE (Trusted Execution Environment) ← casi todos los Android modernos
│ Zona aislada dentro del SoC principal. Hardware-backed.
│
└── Software Keystore ← último recurso (emuladores, dispositivos viejos)
Sin respaldo hardware. La clave AES en RAM del proceso.
// 1. Crear (o recuperar) la clave AES de wrapping en el Keystore
SecretKey wrapKey = getOrCreateWrapKey();
// → intenta StrongBox primero, cae a TEE si no está disponible
// 2. Generar par secp256k1 (BouncyCastle, en RAM)
KeyPair pair = kpg.generateKeyPair();
byte[] privBytes = to32Bytes(priv.getS()); // 32 bytes en RAM
// 3. Cifrar la clave privada con AES/GCM
Cipher enc = Cipher.getInstance("AES/GCM/NoPadding");
enc.init(Cipher.ENCRYPT_MODE, wrapKey); // la clave AES nunca sale del hardware
byte[] iv = enc.getIV();
byte[] encPriv = enc.doFinal(privBytes); // privBytes cifrado
// 4. Persistir el cifrado (nunca la clave en claro)
prefs().edit()
.putString("enc_private_key", base64(encPriv))
.putString("aes_gcm_iv", base64(iv))
.putString("public_key_hex", hex(pubBytes))
.apply();
// privBytes debería limpiarse de RAM aquí (Arrays.fill con 0)// Descifrar la clave privada (toca RAM brevemente)
byte[] privBytes = keyManager.loadPrivateKey();
// → el Keystore descifra usando la clave AES que nunca salió del hardware
// Firmar (BouncyCastle opera en RAM con los bytes)
String jwt = JWTUtil.sign(headerJson, payloadJson, privBytes);
// privBytes debería limpiarse de RAM inmediatamente despuésAES-GCM (Galois/Counter Mode) provee cifrado autenticado: no solo cifra, también genera un tag de autenticación de 128 bits. Si alguien modifica el cifrado en disco, el descifrado falla antes de devolver bytes.
- IV (Initialization Vector): 12 bytes aleatorios generados por el sistema. Nunca se reutiliza con la misma clave.
- Tag: 128 bits al final del ciphertext. Detecta manipulación.
| Paso | Android | Python (gen-*.sh) | Resultado |
|---|---|---|---|
| Generar clave privada | KeyPairGenerator (BouncyCastle) |
generate_private_key(SECP256K1()) |
32 bytes aleatorios |
| Clave pública comprimida | compressedPublicKey() manual |
PublicFormat.CompressedPoint |
33 bytes [0x02/0x03] + x |
| Derivar DID | DIDManager.getDID() |
b58encode([0xe7,0x01] + pub_bytes) |
did:key:zQ3sh... |
| Persistir clave privada | SharedPreferences + AES-GCM (Keystore) |
.holder-keys (texto plano) |
Solo en el dispositivo |
| Firmar Proof JWT | CredentialRequestBuilder.java + JWTUtil.sign() |
gen-proof.sh |
JWT ES256K compacto |
| Firmar VP JWT | VPBuilder.java + JWTUtil.sign() |
gen-vp.sh |
JWT ES256K compacto |
| Formato firma ES256K | R‖S (64 bytes) manual (BouncyCastle) |
decode_dss_signature + r.to_bytes + s.to_bytes |
Idéntico |
| Codificación base64url | Base64Url.java propio |
base64.urlsafe_b64encode(...).rstrip(b"=") |
Idéntico |
Los scripts Python guardan la clave privada en .holder-keys en texto plano (hex). Es solo para desarrollo y pruebas. En Android, la clave privada nunca existe en texto plano en disco — siempre está cifrada con una clave que vive en hardware.
| Archivo | Responsabilidad |
|---|---|
android/.../security/KeyManager.java |
Genera, cifra y carga la clave privada secp256k1 |
android/.../did/DIDManager.java |
Deriva el DID y el key ID desde la clave pública |
android/.../credential/CredentialRequestBuilder.java |
Construye y firma el Proof JWT |
android/.../presentation/VPBuilder.java |
Construye y firma la VP JWT |
android/.../util/JWTUtil.java |
Implementa ES256K y el formato JWT compacto |
android/.../util/Base58.java |
Encoding Base58btc para el DID |
scripts/gen-holder.sh |
Equivalente Python de KeyManager + DIDManager |
scripts/gen-proof.sh |
Equivalente Python de CredentialRequestBuilder (Proof JWT) |
scripts/gen-vp.sh |
Equivalente Python de VPBuilder (VP JWT) — envía al backend y muestra resultado |