Saltar al contenido
ecdsa.com
← Todos los artículos

SD-JWT y SD-JWT VC: divulgación selectiva en carteras EUDI

Un JWT firmado muestra todos sus claims a cualquiera que lo vea. SD-JWT permite que el titular de una credencial revele solo los claims que necesita un verificador, mientras la firma del emisor sigue cubriéndolos todos. Aquí tienes el mecanismo byte a byte, un emisor, una cartera y un verificador que funcionan, contrastados con una biblioteca, y qué lugar ocupa SD-JWT en la Cartera Europea de Identidad Digital.

Respuesta breve. SD-JWT (RFC 9901, noviembre de 2025) sustituye cada claim (declaración) ocultable de un JWT firmado por el hash de una divulgación (disclosure): un pequeño array JSON, codificado en base64url, con una sal aleatoria, el nombre del claim y su valor. El emisor firma los hashes; después, el titular envía el JWT firmado junto con solo las divulgaciones que elige, unidas con ~. Un verificador vuelve a calcular los hashes y comprueba que cada uno aparece en la carga útil firmada. Un JWT de vinculación de clave (key binding JWT, typ: kb+jwt), firmado con una clave indicada en la credencial, ata una presentación a un único verificador (aud), a una única solicitud (nonce) y exactamente a esas divulgaciones (sd_hash). SD-JWT VC añade la capa de credencial: typ: dc+sd-jwt, un tipo de credencial en vct, el estado y metadatos de tipo. Todavía es un Internet-Draft, pero es uno de los dos formatos de credencial que toda Cartera Europea de Identidad Digital debe admitir, junto con mdoc de ISO/IEC 18013-5.

Divulgaciones y resúmenes

La divulgación de una propiedad de objeto es el array [salt, name, value]; la de un elemento de array, [salt, value] (RFC 9901 §4.2). Se codifica en base64url como JSON en UTF-8, y el resumen es el SHA-256 en base64url de esa cadena codificada, no del JSON (§4.2.3). Como el hash cubre exactamente los bytes que eligió el emisor, no hace falta ninguna canonicalización de JSON: el mismo claim codificado con otros espacios en blanco es otra divulgación, con otro resumen. Las dos cosas, comprobadas con el propio ejemplo del RFC:

Salida: el ejemplo del RFC 9901 §4.2.3 (la primera línea debe coincidir con el RFC)
RFC 9901 §4.2.3 digest:   X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
same claim, no spaces:    TZjouOTrBKEwUNjNDs9yeMzBoQn8FFLPaJjRRmAtwrM

El emisor coloca los resúmenes en un array _sd de la carga útil, ordenados o barajados para que su orden no revele nada, y puede añadir resúmenes señuelo que no corresponden a ninguna divulgación (§4.2.5), para que no se filtre el número de claims ocultos. _sd_alg indica el algoritmo de hash; SHA-256 es el valor predeterminado y de implementación obligatoria. La sal hace que los resúmenes no se puedan adivinar: sin ella, un verificador podría calcular el hash de ["birthdate", "1984-01-26"] para cada fecha plausible y encontrar la coincidencia. El RFC 9901 recomienda al menos 128 bits aleatorios por sal, elegidos de forma independiente para cada claim (§9.3).

Tal como se transmite, un SD-JWT es <issuer-signed JWT>~<disclosure>~…~<disclosure>~, y una presentación con vinculación de clave sustituye la última parte, vacía, por el JWT de vinculación de clave: …~<disclosure>~<KB-JWT> (§4). El ~ final es lo que permite a un verificador distinguir los dos casos.

Emisor, cartera y verificador en 76 líneas

El módulo siguiente implementa el caso plano, en el que cada claim ocultable es una propiedad de nivel superior, y eso basta para ver todas las reglas en acción. Las divulgaciones anidadas y recursivas siguen los mismos pasos en los objetos internos (§6).

sd-jwt.mjs (jose 6.2.13, Node.js 24.22)
import { createHash, randomBytes } from "node:crypto";
import { SignJWT, calculateJwkThumbprint, decodeJwt, importJWK, jwtVerify } from "jose";

const b64url = (text) => Buffer.from(text, "utf8").toString("base64url");
// RFC 9901 §4.2.3: SHA-256 over the ASCII of the base64url Disclosure, then base64url.
export const digest = (disclosure) => createHash("sha256").update(disclosure, "ascii").digest("base64url");
const salt = () => randomBytes(16).toString("base64url"); // 128 bits (§9.3)

// --- Issuer ------------------------------------------------------------------
/** claims: always-visible claims; hidden: claims to make selectively disclosable. */
export async function issue({ issuerKey, holderJwk, claims, hidden, decoys = 2 }) {
  const disclosures = [];
  const sd = [];
  for (const [name, value] of Object.entries(hidden)) {
    const d = b64url(JSON.stringify([salt(), name, value]));
    disclosures.push(d);
    sd.push(digest(d));
  }
  for (let i = 0; i < decoys; i++) sd.push(digest(salt())); // decoy digests (§4.2.5)
  sd.sort(); // order must not reveal which claim is which
  const jwt = await new SignJWT({ ...claims, _sd: sd, _sd_alg: "sha-256", cnf: { jwk: holderJwk } })
    .setProtectedHeader({ alg: "ES256", typ: "dc+sd-jwt", kid: "issuer-2026-10" })
    .sign(issuerKey);
  return `${jwt}~${disclosures.join("~")}~`;
}

// --- Holder ------------------------------------------------------------------
export async function present({ sdJwt, reveal, holderKey, aud, nonce }) {
  const [jwt, ...rest] = sdJwt.split("~");
  const chosen = rest.filter(Boolean).filter((d) => reveal.includes(JSON.parse(Buffer.from(d, "base64url"))[1]));
  const presented = `${[jwt, ...chosen].join("~")}~`;
  const kb = await new SignJWT({ aud, nonce, sd_hash: digest(presented) }) // sd_hash: §4.3.1
    .setProtectedHeader({ alg: "ES256", typ: "kb+jwt" })
    .setIssuedAt()
    .sign(holderKey);
  return presented + kb;
}

// --- Verifier ----------------------------------------------------------------
export async function verify(presentation, { issuerJwks, issuer, vct, aud, nonce, maxKbAge = 60 }) {
  const parts = presentation.split("~");
  const kbJwt = parts.pop(); // "" when there is no Key Binding JWT
  if (!kbJwt) throw new Error("key binding required, none presented"); // §7.3 step 2
  const [jwt, ...disclosures] = parts;

  // §7.1 step 2: issuer signature, typ, algorithm, issuer, hash algorithm.
  const { payload } = await jwtVerify(jwt, issuerJwks, { algorithms: ["ES256"], typ: "dc+sd-jwt", issuer });
  if (payload._sd_alg !== "sha-256") throw new Error(`unsupported _sd_alg ${payload._sd_alg}`);
  if (payload.vct !== vct) throw new Error(`unexpected vct ${payload.vct}`);

  // §7.1 steps 3-5 (top-level object properties only, which is all this issuer emits).
  const byDigest = new Map(disclosures.map((d) => [digest(d), d]));
  if (byDigest.size !== disclosures.length) throw new Error("duplicate disclosure");
  if (new Set(payload._sd).size !== payload._sd.length) throw new Error("digest appears twice");
  const { _sd, _sd_alg, ...claims } = payload;
  for (const sdDigest of _sd) {
    const d = byDigest.get(sdDigest);
    if (!d) continue; // undisclosed claim or decoy
    byDigest.delete(sdDigest);
    const decoded = JSON.parse(Buffer.from(d, "base64url"));
    if (!Array.isArray(decoded) || decoded.length !== 3) throw new Error("not an object-property disclosure");
    const [, name, value] = decoded;
    if (name === "_sd" || name === "..." || name in claims) throw new Error(`claim name ${name} not allowed`);
    claims[name] = value;
  }
  if (byDigest.size > 0) throw new Error("disclosure not referenced by the issuer-signed JWT"); // step 5

  // §7.3 step 5: Key Binding JWT signed by the key in cnf, fresh, for us, over exactly this presentation.
  const holderKey = await importJWK(payload.cnf.jwk, "ES256");
  const { payload: kb } = await jwtVerify(kbJwt, holderKey, { algorithms: ["ES256"], typ: "kb+jwt", audience: aud, maxTokenAge: maxKbAge });
  if (kb.nonce !== nonce) throw new Error("key binding nonce mismatch");
  if (kb.sd_hash !== digest(`${parts.join("~")}~`)) throw new Error("sd_hash does not match the presented SD-JWT");
  return { claims, holderThumbprint: await calculateJwkThumbprint(payload.cnf.jwk) };
}

export const peek = (jwt) => decodeJwt(jwt);

Las reglas del verificador corresponden una a una con el RFC 9901 §7.1 y el §7.3: rechazar none; comprobar el emisor y su clave; admitir el _sd_alg; hacer corresponder cada divulgación con exactamente un resumen; rechazar las divulgaciones cuyo nombre sea _sd o ..., o coincida con el de un claim visible; rechazar un resumen que aparezca dos veces y una divulgación a la que nada haga referencia; y, por último, comprobar en el JWT de vinculación de clave la firma con la clave de cnf, el typ, la antigüedad, aud, nonce y sd_hash. Fíjate en el paso 1 del §7.3: exigir o no la vinculación de clave es una política del verificador, y nunca depende de que el titular haya enviado o no un KB-JWT.

Ejecución y contraste

El script de prueba emite una credencial de identidad con cinco claims ocultables (nombre, apellido, fecha de nacimiento, dirección y umbrales de edad), presenta a una tienda solo los umbrales de edad, verifica la presentación con el módulo anterior y con @sd-jwt/sd-jwt-vc 0.23.0, de la OpenWallet Foundation, y después prueba seis manipulaciones contra ambos:

Salida (node:24.22.0-bookworm-slim)
RFC 9901 §4.2.3 digest:   X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
same claim, no spaces:    TZjouOTrBKEwUNjNDs9yeMzBoQn8FFLPaJjRRmAtwrM
issued SD-JWT: 1438 chars, 5 disclosures, _sd has 7 digests (2 decoys)
presentation: 1385 chars, 1 disclosure
hand-written verifier: {"iss":"https://issuer.example","vct":"https://credentials.example/identity_credential","iat":1792499582,"exp":1824035582,"age_equal_or_over":{"18":true,"21":true,"65":false}}
@sd-jwt/sd-jwt-vc: age_equal_or_over {"18":true,"21":true,"65":false}, given_name undefined, kb verified
disclosure with a changed value        | disclosure not referenced by the issuer-signed JWT | Unreferenced disclosure(s) detected in SD-JWT
same presentation, another verifier    | unexpected "aud" claim value | Verify Error: Invalid Key Binding audience
same presentation, a new nonce         | key binding nonce mismatch | Verify Error: Invalid Nonce
extra disclosure appended              | sd_hash does not match the presented SD-JWT | Invalid sd_hash in Key Binding JWT
key binding JWT removed                | key binding required, none presented | Key Binding JWT not exist
key binding by another key             | signature verification failed | Verify Error: Invalid JWT Signature

Los dos verificadores coinciden en todos los casos. Qué dicen las líneas:

  • La divulgación selectiva funciona. La tienda recibió age_equal_or_over; given_name es undefined en el resultado de la biblioteca porque su divulgación nunca se envió. Los dos verificadores reconstruyeron los mismos claims.
  • Un titular no puede modificar un claim. Al cambiar la divulgación de la edad se obtuvo un resumen que el emisor nunca firmó.
  • Una presentación sirve para un único verificador y una única solicitud. Repetida ante otra audiencia, o enviada con un nonce antiguo, falló.
  • sd_hash congela la selección. Añadir una divulgación después de que la cartera firmara el JWT de vinculación de clave invalidó la presentación.
  • Sin la clave de la cartera, la credencial no sirve de nada, si el verificador exige la vinculación de clave: se rechazaron tanto la presentación sin JWT de vinculación de clave como la que llevaba uno firmado con otra clave.

Y la ejecución muestra algo que es una lección de diseño más que una comprobación: la tienda pidió «mayor de 18» y supo además «no mayor de 65», porque el emisor metió todos los umbrales en una sola divulgación. La granularidad se decide en la emisión. El RFC 9901 §6.2 muestra la forma estructurada con un objeto address: cada propiedad tiene su propia divulgación, y el objeto conserva un único array _sd con un resumen por propiedad. Emitido así, age_equal_or_over permitiría a la cartera revelar solo "18": true.

Lo que la divulgación selectiva no oculta

La divulgación selectiva oculta valores, no la credencial. Todas las presentaciones de un mismo SD-JWT llevan la misma firma del emisor, los mismos resúmenes _sd y la misma clave cnf, así que dos verificadores que comparen sus datos pueden saber que vieron la misma credencial, aunque uno solo recibiera una edad y el otro solo un código postal. El RFC 9901 §10.1 lo dice sin rodeos: el formato «no cumple las propiedades de seguridad de las credenciales anónimas», y un emisor al que un verificador le haga llegar presentaciones siempre puede reconocer su propia credencial. Lo mismo vale para mdoc, que usa el mismo enfoque de hashes con sal.

La mitigación que describe el RFC es la emisión por lotes (batch issuance): el emisor entrega a la cartera varias credenciales con los mismos claims, y la cartera usa una distinta para cada verificador o incluso para cada sesión. Para que funcione, cada credencial del lote necesita sus propias sales y su propia clave de vinculación, y los claims de tiempo como iat y exp deben aleatorizarse o redondearse, porque las marcas de tiempo exactas permiten correlacionar presentaciones igual que un número de serie. La emisión por lotes impide que los verificadores correlacionen presentaciones entre sí; no puede ocultarle una presentación al propio emisor. Las comprobaciones de estado son una filtración afín: un verificador que pregunta al emisor por una credencial le revela dónde se usa, y por eso la Token Status List permite a los verificadores descargar una lista comprimida que abarca muchas credenciales en lugar de preguntar por una sola.

Qué añade SD-JWT VC

SD-JWT es un contenedor. Credenciales verificables basadas en SD-JWT define cómo usarlo para credenciales:

  • typ: dc+sd-jwt, tipo de medio application/dc+sd-jwt. Hasta la versión -06, el borrador usaba vc+sd-jwt; se cambió para evitar el nombre vc que usa el modelo de datos de credenciales verificables del W3C (Verifiable Credentials Data Model). Los verificadores que se toparon con implementaciones tempranas todavía pueden ver vc+sd-jwt.
  • vct, obligatorio: el tipo de credencial, una cadena resistente a colisiones. Los tipos pueden tener metadatos de tipo legibles por máquina, con esquemas y datos de visualización.
  • iss, cnf (la clave del titular, obligatoria cuando se usa la vinculación de clave), status (revocación mediante una Token Status List) y los claims de validez. Estos claims, junto con vct, no deben poder divulgarse de forma selectiva: un verificador siempre debe ver quién emitió la credencial, a quién está vinculada y si sigue siendo válida.

Estado a 20 de octubre de 2026: SD-JWT VC va por el borrador -19 (31 de agosto de 2026), está enviado al IESG y todavía no es un RFC. La Token Status List que usa para la revocación está aprobada y en la cola del RFC Editor. El propio RFC 9901 ya está publicado.

La Cartera Europea de Identidad Digital

El Reglamento eIDAS revisado (Reglamento (UE) 2024/1183) obliga a cada Estado miembro a facilitar al menos una Cartera Europea de Identidad Digital en un plazo de 24 meses desde la entrada en vigor de sus principales actos de ejecución. Esos actos, entre ellos el Reglamento de Ejecución (UE) 2024/2977 sobre los datos de identificación de la persona, se publicaron el 4 de diciembre de 2024 y entraron en vigor el 24 de diciembre de 2024, lo que sitúa el plazo límite en el 24 de diciembre de 2026.

La parte técnica se describe en el marco de arquitectura y referencia (Architecture and Reference Framework, ARF), cuya versión 3.0.0 se publicó el 23 de julio de 2026 (el ARF en sí es informativo; el texto vinculante son los actos de ejecución). Su capítulo 5 dice:

  • Las carteras admiten dos formatos de credencial: mdoc de ISO/IEC 18013-5 y SD-JWT VC. W3C VCDM 2.0 es opcional y solo para atestaciones no cualificadas.
  • Las credenciales mdoc se presentan en proximidad mediante ISO/IEC 18013-5, y a distancia mediante ISO/IEC 18013-7 u OpenID for Verifiable Presentations con el perfil HAIP. Las credenciales SD-JWT VC solo se presentan a distancia, mediante OpenID4VP y HAIP; «no pueden usarse en presentaciones en proximidad».
  • Los datos de identificación de la persona (PID) usan el tipo de documento mdoc eu.europa.ec.eudi.pid.1 y el tipo SD-JWT VC urn:eudi:pid:1, con tipos nacionales como urn:eudi:pid:de:1 en el mismo espacio de nombres (PID Rulebook).

Los protocolos OpenID que la rodean ya son definitivos: OpenID4VP 1.0 (julio de 2025), OpenID4VCI 1.0 (septiembre de 2025) y HAIP 1.0 (diciembre de 2025), que define un perfil de SD-JWT VC y mdoc para usos con un nivel de garantía alto.

Comparación de SD-JWT VC y mdoc

El ARF califica los dos mecanismos de divulgación selectiva de «conceptualmente idénticos»: ambos firman resúmenes con sal de atributos individuales. La diferencia está en todo lo que los rodea. La columna de mdoc usa los nombres de estructura de la norma ISO, que no es de libre acceso; la biblioteca de código abierto @auth0/mdl expone los mismos nombres en sus tipos.

SD-JWT VCISO/IEC 18013-5 mdoc
CodificaciónJSON, JWS, base64urlCBOR, COSE
Atributo ocultodivulgación [salt, name, value]IssuerSignedItem con random, digestID, elementIdentifier, elementValue
Dónde están los resúmenesarrays _sd en el JWT firmado por el emisorvalueDigests por espacio de nombres en el Mobile Security Object (MSO), firmado por el emisor
Vinculación con el titularJWT de vinculación de clave, opcional en el formatoclave del dispositivo en el MSO y autenticación del dispositivo, obligatoria
Confianza en el emisorclave del emisor, por ejemplo mediante x5c o los metadatos del emisorcadena de certificados del emisor hasta una autoridad emisora
Presentación en la cartera EUDIsolo a distancia (OpenID4VP + HAIP)en proximidad (NFC, BLE, QR) y a distancia
Origengrupo de trabajo OAuth del IETFestándar del permiso de conducir móvil

Para un verificador, ser compatible con la cartera significa aceptar los dos formatos, y en la práctica eso supone una biblioteca para cada uno: el análisis de mdoc y la verificación COSE no son algo que convenga implementar a mano.

Errores frecuentes

  1. Calcular el hash del JSON decodificado en lugar de la cadena base64url. Ningún resumen coincidirá; el ejemplo del RFC de más arriba sirve de prueba.
  2. Sales cortas o reutilizadas. Hacen que los valores ocultos se puedan adivinar o correlacionar entre claims.
  3. Tratar la ausencia de KB-JWT como «no hace falta vinculación de clave». Decide la política del verificador, no la presentación.
  4. No comprobar aud ni nonce en el JWT de vinculación de clave. Entonces cualquier presentación que un titular haya hecho alguna vez puede repetirse ante ti.
  5. Divulgaciones poco granulares. Una divulgación por objeto revela el objeto entero; diseña la estructura según las preguntas que harán los verificadores.
  6. Ignorar status. Una credencial puede estar firmada, no haber caducado y estar revocada.

Fuentes