Pular para o conteúdo
ecdsa.com
← Todos os artigos

SD-JWT e SD-JWT VC: divulgação seletiva em carteiras EUDI

Um JWT assinado mostra todas as claims a qualquer pessoa que o veja. O SD-JWT permite que o portador de uma credencial revele apenas as claims de que o verificador precisa, enquanto a assinatura do emissor continua cobrindo todas elas. Veja o mecanismo byte a byte, um emissor, uma carteira e um verificador que funcionam, conferidos com uma biblioteca, e onde tudo isso se encaixa na Carteira Europeia de Identidade Digital.

Resposta curta. O SD-JWT (RFC 9901, novembro de 2025) substitui cada claim ocultável de um JWT assinado pelo hash de uma disclosure: um pequeno array JSON codificado em base64url com um salt aleatório, o nome da claim e o valor dela. O emissor assina os hashes; depois, o portador envia o JWT assinado e apenas as disclosures que escolher, unidos por ~. O verificador recalcula os hashes e confere se cada um aparece no payload assinado. Um JWT de vinculação de chave (key binding JWT, typ: kb+jwt), assinado por uma chave indicada na credencial, amarra a apresentação a um único verificador (aud), a uma única requisição (nonce) e exatamente a essas disclosures (sd_hash). O SD-JWT VC acrescenta a camada de credencial: typ: dc+sd-jwt, um tipo de credencial em vct, status e metadados de tipo. Ele ainda é um rascunho do IETF (Internet-Draft), mas é um dos dois formatos de credencial que toda Carteira Europeia de Identidade Digital (EUDI Wallet) deve aceitar, ao lado do mdoc da ISO/IEC 18013-5.

Disclosures e digests

A disclosure de uma propriedade de objeto é o array [salt, name, value]; a de um elemento de array é [salt, value] (RFC 9901 §4.2). Ela é codificada em base64url como JSON em UTF-8, e o digest é o SHA-256 em base64url dessa string codificada, e não do JSON (§4.2.3). Como o hash cobre exatamente os bytes que o emissor escolheu, nenhuma canonicalização de JSON é necessária: a mesma claim codificada com outros espaços em branco é outra disclosure, com outro digest. Os dois pontos, conferidos com o próprio exemplo da RFC:

Saída: o exemplo da RFC 9901 §4.2.3 (a primeira linha deve coincidir com a RFC)
RFC 9901 §4.2.3 digest:   X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
same claim, no spaces:    TZjouOTrBKEwUNjNDs9yeMzBoQn8FFLPaJjRRmAtwrM

O emissor coloca os digests em um array _sd no payload, ordenados ou embaralhados para que a ordem não revele nada, e pode acrescentar digests fictícios, que não correspondem a nenhuma disclosure (§4.2.5), para que o número de claims ocultas não vaze. _sd_alg indica a função de hash; o SHA-256 é o padrão e tem implementação obrigatória. O salt torna os digests impossíveis de adivinhar: sem ele, um verificador poderia calcular o hash de ["birthdate", "1984-01-26"] para cada data plausível e encontrar a que corresponde. A RFC 9901 recomenda pelo menos 128 bits aleatórios por salt, escolhidos de forma independente para cada claim (§9.3).

Na transmissão, um SD-JWT tem a forma <issuer-signed JWT>~<disclosure>~…~<disclosure>~, e uma apresentação com vinculação de chave substitui a última parte, vazia, pelo JWT de vinculação de chave: …~<disclosure>~<KB-JWT> (§4). É pelo ~ final que o verificador distingue as duas formas.

Emissor, carteira e verificador em 76 linhas

O módulo abaixo implementa o caso plano, em que toda claim ocultável é uma propriedade de nível superior, o que basta para ver cada regra em ação. Disclosures aninhadas e recursivas seguem os mesmos passos nos 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);

As regras do verificador correspondem uma a uma às da RFC 9901 §7.1 e da §7.3: rejeitar none; verificar o emissor e a chave dele; reconhecer o _sd_alg; associar cada disclosure a exatamente um digest; rejeitar disclosures com o nome _sd ou ... ou que colidam com uma claim visível; rejeitar um digest que apareça duas vezes e uma disclosure à qual nada se refere; depois, verificar a assinatura do JWT de vinculação de chave com a chave em cnf, além do typ, da idade, do aud, do nonce e do sd_hash dele. Atenção ao passo 1 da §7.3: exigir ou não a vinculação de chave é uma política do verificador e nunca depende de o portador ter enviado, por acaso, um KB-JWT.

Execução e verificação cruzada

O script de teste emite uma credencial de identidade com cinco claims ocultáveis (nome, sobrenome, data de nascimento, endereço e limites de idade), apresenta apenas os limites de idade a uma loja, verifica a apresentação com o módulo acima e com o @sd-jwt/sd-jwt-vc 0.23.0, da OpenWallet Foundation, e depois tenta seis manipulações contra os dois:

Saída (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

Os dois verificadores concordam em todos os casos. O que as linhas mostram:

  • A divulgação seletiva funciona. A loja recebeu age_equal_or_over; given_name é undefined no resultado da biblioteca porque a disclosure dessa claim nunca foi enviada. Os dois verificadores reconstruíram as mesmas claims.
  • O portador não consegue editar uma claim. Alterar a disclosure da idade gerou um digest que o emissor nunca assinou.
  • Uma apresentação vale para um verificador e uma requisição. Reenviada para outro público-alvo, ou com um nonce antigo, ela foi rejeitada.
  • O sd_hash congela a seleção. Acrescentar uma disclosure depois que a carteira assinou o JWT de vinculação de chave invalidou a apresentação.
  • Sem a chave da carteira, a credencial não serve para nada, se o verificador exigir vinculação de chave: um JWT de vinculação de chave removido ou assinado de novo por outra chave foi rejeitado.

E a execução mostra algo que é mais uma lição de projeto do que uma verificação: a loja pediu “maior de 18” e ficou sabendo também “não é maior de 65”, porque o emissor colocou todos os limites em uma única disclosure. A granularidade é definida na emissão. A RFC 9901 §6.2 mostra a forma estruturada em um objeto address: cada propriedade recebe sua própria disclosure, e o objeto mantém um único array _sd, com um digest por propriedade. Emitida dessa forma, a claim age_equal_or_over permitiria que a carteira liberasse só "18": true.

O que a divulgação seletiva não oculta

A divulgação seletiva oculta valores, não a credencial. Toda apresentação de um mesmo SD-JWT leva a mesma assinatura do emissor, os mesmos digests em _sd e a mesma chave em cnf, então dois verificadores que comparem o que receberam conseguem perceber que viram a mesma credencial, mesmo que um tenha recebido só uma idade e o outro só um código postal. A RFC 9901 §10.1 é direta sobre isso: o formato “não atende às propriedades de segurança de credenciais anônimas”, e um emissor que recebe de volta as apresentações de um verificador sempre consegue reconhecer a própria credencial. O mesmo vale para o mdoc, que usa a mesma abordagem de hashes com salt.

A atenuação que a RFC descreve é a emissão em lote (batch issuance): o emissor entrega à carteira várias credenciais com as mesmas claims, e a carteira usa uma diferente para cada verificador ou até para cada sessão. Para isso funcionar, cada credencial do lote precisa de salts próprios e de uma chave de vinculação própria, e as claims de tempo, como iat e exp, devem ser aleatorizadas ou arredondadas, porque timestamps exatos permitem relacionar apresentações tão bem quanto um número de série. A emissão em lote impede que os verificadores relacionem apresentações entre si; ela não consegue esconder uma apresentação do próprio emissor. As consultas de status são um vazamento parecido: um verificador que pergunta ao emissor sobre uma credencial revela a ele onde ela é usada, e é por isso que a Token Status List permite que os verificadores baixem uma lista compactada que cobre muitas credenciais, em vez de perguntar sobre uma só.

O que o SD-JWT VC acrescenta

O SD-JWT é um contêiner. A especificação Credenciais verificáveis baseadas em SD-JWT define como usá-lo para credenciais:

  • typ: dc+sd-jwt, com o tipo de mídia application/dc+sd-jwt. Até a versão -06, o rascunho usava vc+sd-jwt; o nome mudou para evitar o vc usado pelo modelo de dados de credenciais verificáveis do W3C (Verifiable Credentials Data Model). Verificadores que lidam com implementações antigas ainda podem encontrar vc+sd-jwt.
  • vct, obrigatório: o tipo da credencial, uma string resistente a colisões. Os tipos podem ter metadados de tipo legíveis por máquina, para schemas e exibição.
  • iss, cnf (a chave do portador, obrigatória quando se usa vinculação de chave), status (revogação por meio de uma Token Status List) e as claims de validade. Essas claims, junto com vct, não devem ser divulgáveis seletivamente: o verificador deve sempre ver quem emitiu a credencial, a quem ela está vinculada e se ainda é válida.

Situação em 20 de outubro de 2026: o SD-JWT VC está no rascunho -19 (31 de agosto de 2026), já submetido ao IESG, e ainda não é uma RFC. A Token Status List, que ele usa para revogação, foi aprovada e está na fila do RFC Editor. A própria RFC 9901 já foi publicada.

A Carteira Europeia de Identidade Digital

O Regulamento eIDAS revisado (Regulamento (UE) 2024/1183) exige que cada Estado-membro disponibilize pelo menos uma Carteira Europeia de Identidade Digital em até 24 meses após a entrada em vigor dos principais atos de execução do regulamento. Esses atos, entre eles o Regulamento de Execução (UE) 2024/2977, sobre os dados de identificação pessoal, foram publicados em 4 de dezembro de 2024 e entraram em vigor em 24 de dezembro de 2024, o que fixa o prazo em 24 de dezembro de 2026.

A parte técnica é descrita no Architecture and Reference Framework, cuja versão 3.0.0 foi lançada em 23 de julho de 2026 (o ARF em si é informativo; o texto vinculante são os atos de execução). O capítulo 5 diz:

  • As carteiras aceitam dois formatos de credencial: o mdoc da ISO/IEC 18013-5 e o SD-JWT VC. O W3C VCDM 2.0 é opcional e vale apenas para atestados não qualificados.
  • Credenciais mdoc são apresentadas por proximidade pela ISO/IEC 18013-5 e remotamente pela ISO/IEC 18013-7 ou pelo OpenID for Verifiable Presentations com o perfil HAIP. Credenciais SD-JWT VC são apresentadas apenas remotamente, pelo OpenID4VP com o HAIP; elas “não podem ser usadas em apresentações por proximidade”.
  • Os dados de identificação pessoal (PID) usam o tipo de documento mdoc eu.europa.ec.eudi.pid.1 e o tipo SD-JWT VC urn:eudi:pid:1, com tipos nacionais, como urn:eudi:pid:de:1, no mesmo namespace (PID Rulebook).

Os protocolos OpenID em torno da carteira já são finais: OpenID4VP 1.0 (julho de 2025), OpenID4VCI 1.0 (setembro de 2025) e HAIP 1.0 (dezembro de 2025), que define perfis do SD-JWT VC e do mdoc para uso de alta garantia.

SD-JWT VC e mdoc lado a lado

O ARF chama os dois mecanismos de divulgação seletiva de “conceitualmente idênticos”: ambos assinam digests com salt de atributos individuais. A diferença está em tudo o que os cerca. A coluna do mdoc usa os nomes de estrutura da norma ISO, que não é de acesso livre; a biblioteca de código aberto @auth0/mdl expõe os mesmos nomes em seus tipos.

SD-JWT VCmdoc da ISO/IEC 18013-5
CodificaçãoJSON, JWS, base64urlCBOR, COSE
Atributo ocultodisclosure [salt, name, value]IssuerSignedItem com random, digestID, elementIdentifier, elementValue
Onde ficam os digestsarrays _sd no JWT assinado pelo emissorvalueDigests por namespace no Mobile Security Object (MSO), assinado pelo emissor
Vinculação ao portadorJWT de vinculação de chave, opcional no formatochave do dispositivo no MSO e autenticação do dispositivo, obrigatórias
Confiança no emissorchave do emissor, por exemplo via x5c ou metadados do emissorcadeia de certificados do emissor até uma autoridade emissora
Apresentação na carteira EUDIsó remota (OpenID4VP + HAIP)por proximidade (NFC, BLE, QR) e remota
Origemgrupo de trabalho OAuth do IETFnorma da habilitação digital (mobile driving licence)

Para um verificador, oferecer suporte à carteira significa aceitar os dois formatos, o que na prática exige uma biblioteca para cada um: análise de mdoc e verificação COSE não são coisas para implementar à mão.

Erros comuns

  1. Calcular o hash do JSON decodificado em vez da string base64url. Nenhum digest vai coincidir; o exemplo da RFC acima serve de teste.
  2. Salts curtos ou reutilizados. Eles permitem adivinhar os valores ocultos ou relacioná-los entre claims.
  3. Tratar a falta de um KB-JWT como “vinculação de chave dispensada”. Quem decide é a política do verificador, não a apresentação.
  4. Não verificar aud e nonce no JWT de vinculação de chave. Assim, qualquer apresentação que um portador já tenha feito pode ser reenviada a você.
  5. Disclosures pouco granulares. Uma disclosure por objeto revela o objeto inteiro; projete a estrutura para as perguntas que os verificadores vão fazer.
  6. Ignorar o status. Uma credencial pode estar assinada, dentro da validade e revogada.

Fontes