Pular para o conteúdo
ecdsa.com

SD-JWT VC vs mdoc: credenciais de carteiras digitais

Ambos permitem que uma carteira revele apenas os atributos que o verificador pede, assinados pelo emissor e vinculados a uma chave no dispositivo do portador. Um é construído com JSON e JOSE; o outro, com CBOR e COSE.

SD-JWT VC vs mdoc é a questão de formato por trás das carteiras digitais de identidade, das habilitações digitais e da Carteira Europeia de Identidade Digital (EUDI Wallet). Um SD-JWT (RFC 9901) é um JWT cujas claims são substituídas por digests com salt, e os valores correspondentes seguem junto como disclosures separadas; o SD-JWT VC acrescenta tipos de credencial e a descoberta da chave do emissor. Um mdoc (ISO/IEC 18013-5) é um documento CBOR cujos elementos de dados entram, como digests, em um Mobile Security Object assinado com COSE. Ambos revelam apenas os atributos que o portador escolhe, e ambos provam que quem apresenta a credencial possui a chave do dispositivo.

Visão geral

SD-JWT VCmdoc (ISO/IEC 18013-5)
Especificação principalRFC 9901 SD-JWT (novembro de 2025); SD-JWT VC (rascunho do IETF, submetido ao IESG)ISO/IEC 18013-5:2021; apresentação online na ISO/IEC TS 18013-7:2025
CodificaçãoJSON, base64url, disclosures separadas por ~CBOR (RFC 8949)
Assinatura do emissorJWS, normalmente ES256COSE_Sign1 sobre o Mobile Security Object
Divulgação seletivaDigest com salt por claim em _sd; objetos aninhados e elementos de arraysDigest com salt por elemento de dados, por namespace; listas planas de elementos
Vinculação ao portadorChave em cnf; JWT de vinculação de chave com aud, nonce, sd_hashChave do dispositivo no MSO; assinatura ou MAC do dispositivo sobre a transcrição da sessão
Tipo de credencialURI vct com metadados de tipodocType, por exemplo org.iso.18013.5.1.mDL
Descoberta da chave do emissor.well-known/jwt-vc-issuer, x5c ou DIDx5chain com uma raiz IACA (listas de confiança nacionais)
RevogaçãoToken Status List (rascunho do IETF na fila do RFC Editor)Validade curta, listas de status em perfis mais recentes
Apresentação presencialNão definidaEngajamento do dispositivo (device engagement) por NFC, BLE e QR code
Apresentação onlineOpenID4VP 1.0 (dc+sd-jwt)OpenID4VP 1.0 (mso_mdoc), anexo C da ISO 18013-7, Digital Credentials API
Medido abaixo: emissão; apresentação de uma das cinco claims1.112 bytes; 1.159 bytes1.046 bytes; 895 bytes (1.194 em base64url)

Como a divulgação seletiva funciona em cada um

No SD-JWT, o emissor transforma cada claim em uma disclosure, a codificação base64url de [salt, name, value], e assina apenas os digests SHA-256 no array _sd do JWT. O portador envia o JWT e as disclosures que escolher, separados por ~. O verificador calcula o hash de cada disclosure e confere se o digest está em _sd; as claims sem disclosure continuam ocultas, e o salt impede que sejam adivinhadas a partir do digest. Um JWT de vinculação de chave (typ: kb+jwt), assinado com a chave do portador, traz o aud e o nonce do verificador e um sd_hash calculado sobre exatamente essa apresentação.

No mdoc, cada elemento de dados é um IssuerSignedItem (ID do digest, salt aleatório, identificador, valor) codificado em CBOR e encapsulado na tag 24. O Mobile Security Object lista os digests SHA-256 por namespace, a chave pública do dispositivo e o período de validade, e o emissor o assina com COSE. O portador devolve apenas os itens selecionados, com o MSO assinado pelo emissor, além de uma assinatura do dispositivo sobre a transcrição da sessão; no OpenID4VP, essa transcrição contém um hash do ID de cliente do verificador, do nonce e da URI de resposta.

O script abaixo monta os dois formatos com os mesmos cinco atributos, assina-os com chaves P-256 e apresenta apenas age_over_18. Antes, ele confere o cálculo do digest do SD-JWT com o exemplo da RFC 9901 §4.2.3 (Node.js 26, jose 6.2.13, cbor-x 1.6.0).

Montar os dois formatos e apresentar um atributo
import { createHash, generateKeyPairSync, randomBytes, sign } from "node:crypto";
import { SignJWT, exportJWK } from "jose";
import { Encoder, Tag } from "cbor-x";

const sha256 = (b) => createHash("sha256").update(b).digest();
const b64u = (b) => Buffer.from(b).toString("base64url");
const cbor = new Encoder({ useRecords: false, useTag259ForMaps: false, variableMapSize: true, tagUint8Array: false });
const tag24 = (v) => new Tag(cbor.encode(v), 24);

// RFC 9901 §4.2.3 example: digest of the family_name Disclosure
const rfcDisclosure = "WyJfMjZiYzRMVC1hYzZxMktJNmNCVzVlcyIsICJmYW1pbHlfbmFtZSIsICJNw7ZiaXVzIl0";
console.log("RFC 9901 example digest:", b64u(sha256(rfcDisclosure)));

const claims = { given_name: "Erika", family_name: "Mustermann", birth_date: "1984-01-26", age_over_18: true, nationality: "DE" };
const issuer = generateKeyPairSync("ec", { namedCurve: "P-256" });
const holder = generateKeyPairSync("ec", { namedCurve: "P-256" });
const holderJwk = await exportJWK(holder.publicKey);
const nonce = b64u(randomBytes(16));

// SD-JWT VC: one salted Disclosure per claim, their digests signed in _sd
const disclosures = Object.entries(claims).map(([k, v]) => b64u(JSON.stringify([b64u(randomBytes(16)), k, v])));
const jwt = await new SignJWT({
  iss: "https://issuer.example", vct: "https://credentials.example/identity_credential",
  _sd: disclosures.map((d) => b64u(sha256(d))), _sd_alg: "sha-256", cnf: { jwk: holderJwk },
}).setProtectedHeader({ alg: "ES256", typ: "dc+sd-jwt" }).sign(issuer.privateKey);
const issued = `${jwt}~${disclosures.join("~")}~`;
const shown = `${jwt}~${disclosures[3]}~`; // reveal age_over_18 only
const kbJwt = await new SignJWT({ aud: "https://verifier.example", nonce, sd_hash: b64u(sha256(shown)) })
  .setProtectedHeader({ alg: "ES256", typ: "kb+jwt" }).setIssuedAt().sign(holder.privateKey);
console.log(`SD-JWT VC: issued ${issued.length} bytes; presentation of 1 of 5 claims ${(shown + kbJwt).length} bytes`);

// mdoc (ISO/IEC 18013-5): one salted IssuerSignedItem per element, digests signed in the MSO
const ns = "org.iso.18013.5.1";
const docType = "org.iso.18013.5.1.mDL";
const items = Object.entries(claims).map(([k, v], i) =>
  tag24({ digestID: i, random: randomBytes(16), elementIdentifier: k, elementValue: v }));
const coseSign1 = (key, payload, detached) => {
  const prot = cbor.encode(new Map([[1, -7]])); // alg: ES256
  const toBeSigned = cbor.encode(["Signature1", prot, Buffer.alloc(0), payload]);
  return [prot, {}, detached ? null : payload, sign("sha256", toBeSigned, { key, dsaEncoding: "ieee-p1363" })];
};
const mso = {
  version: "1.0", digestAlgorithm: "SHA-256", docType,
  valueDigests: { [ns]: new Map(items.map((t, i) => [i, sha256(cbor.encode(t))])) },
  deviceKeyInfo: { deviceKey: new Map([[1, 2], [-1, 1], [-2, Buffer.from(holderJwk.x, "base64url")], [-3, Buffer.from(holderJwk.y, "base64url")]]) },
  validityInfo: { signed: new Tag("2026-10-20T00:00:00Z", 0), validFrom: new Tag("2026-10-20T00:00:00Z", 0), validUntil: new Tag("2027-10-20T00:00:00Z", 0) },
};
const issuerAuth = coseSign1(issuer.privateKey, cbor.encode(tag24(mso)));
const issuedMdoc = cbor.encode({ nameSpaces: { [ns]: items }, issuerAuth });

// Presentation over OpenID4VP: DeviceResponse signed by the device key over the session transcript
const handoverInfo = cbor.encode(["https://verifier.example", nonce, null, "https://verifier.example/response"]);
const transcript = [null, null, ["OpenID4VPHandover", sha256(handoverInfo)]];
const deviceNameSpaces = tag24({});
const deviceAuth = cbor.encode(tag24(["DeviceAuthentication", transcript, docType, deviceNameSpaces]));
const response = cbor.encode({
  version: "1.0", status: 0,
  documents: [{ docType, issuerSigned: { nameSpaces: { [ns]: [items[3]] }, issuerAuth },
    deviceSigned: { nameSpaces: deviceNameSpaces, deviceAuth: { deviceSignature: coseSign1(holder.privateKey, deviceAuth, true) } } }],
});
console.log(`mdoc:      issued ${issuedMdoc.length} bytes; presentation of 1 of 5 elements ${response.length} bytes (${b64u(response).length} as base64url)`);
Saída
RFC 9901 example digest: X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
SD-JWT VC: issued 1112 bytes; presentation of 1 of 5 claims 1159 bytes
mdoc:      issued 1046 bytes; presentation of 1 of 5 elements 895 bytes (1194 as base64url)

O digest coincide com o valor impresso na RFC 9901. Os tamanhos vêm de um esboço mínimo, sem certificados do emissor: credenciais reais acrescentam uma cadeia de certificados x5c ou x5chain (muitas vezes de 1 a 2 KB), informações de status e mais atributos, o que afeta os dois formatos de modo parecido. O padrão, no entanto, se mantém. Uma apresentação SD-JWT é o JWT completo do emissor, com todos os digests, mais uma disclosure e um JWT de vinculação de chave, enquanto uma apresentação mdoc descarta os itens não selecionados e mantém o MSO; por isso, as apresentações mdoc ficam menores à medida que mais atributos permanecem ocultos.

Onde cada um é usado

O regulamento europeu de identidade digital exige que os Estados-membros disponibilizem carteiras até dezembro de 2026 e que as partes confiáveis privadas obrigadas a usar autenticação forte do usuário, como os bancos, passem a aceitá-las até dezembro de 2027; o Architecture and Reference Framework exige que os provedores emitam os dados de identificação pessoal tanto como mdoc quanto como SD-JWT VC. As habilitações digitais seguem a ISO/IEC 18013-5 no mundo todo, e a TSA dos EUA e muitos estados americanos as aceitam nos pontos de controle.

Na web, o Chrome 141 e o Safari 26 lançaram em 2025 a Digital Credentials API do W3C, que permite a uma página solicitar uma credencial à carteira da plataforma. O Chrome aceita requisições OpenID4VP, então os dois formatos podem ser solicitados; o Safari aceita apenas o protocolo do anexo C da ISO 18013-7 (org-iso-mdoc), então verificadores web que queiram atender usuários de iPhone precisam aceitar mdoc. O OpenID4VP 1.0, final desde julho de 2025, define os formatos dc+sd-jwt e mso_mdoc e a linguagem de consulta DCQL para ambos.

Diferenças práticas para quem implementa

  • Ferramentas: o SD-JWT é JSON e JOSE, então basta qualquer biblioteca de JWT mais umas cem linhas para tratar as disclosures; o inspetor de SD-JWT os decodifica. O mdoc exige CBOR, COSE e o modelo de dados da ISO; o inspetor de mdoc e o decodificador de COSE mostram as estruturas.
  • Modelo de dados: o SD-JWT aceita objetos aninhados e elementos de array divulgáveis individualmente; o mdoc usa namespaces planos de elementos, com identificadores definidos pela ISO, como age_over_18.
  • Não vinculabilidade: os dois reutilizam a mesma assinatura do emissor e os mesmos digests em todas as apresentações, então verificadores em conluio conseguem relacioná-las. As carteiras atenuam isso com a emissão de lotes de credenciais, cada uma usada uma única vez.
  • Confiança: o mdoc depende de cadeias de certificados do emissor até raízes nacionais (IACA), o que se adapta bem a emissores governamentais. O SD-JWT VC aceita tanto a descoberta de chaves pela web quanto X.509.
  • Status dos padrões: a RFC 9901, a ISO/IEC 18013-5 e o OpenID4VP 1.0 são finais. O SD-JWT VC e a Token Status List são rascunhos do IETF em fase final (outubro de 2026), então fixe a versão do rascunho que você implementa.

Quando escolher cada um

  • Identidade governamental, habilitações, verificação de idade presencial: mdoc.
  • Verificação na web que precisa funcionar em iPhones hoje: mdoc pela Digital Credentials API.
  • Dados de identificação pessoal da EUDI Wallet: os dois, como exige o framework de referência.
  • Diplomas, licenças profissionais, credenciais de funcionário ou de associação emitidas online: SD-JWT VC.
  • Sistemas já construídos sobre OAuth e OpenID Connect: SD-JWT VC via OpenID4VCI e OpenID4VP.
  • Claims JSON assinadas sem divulgação seletiva: basta um JWT comum ou uma credencial verificável.

Notas de migração

  1. Implemente o OpenID4VP 1.0 com DCQL, que solicita qualquer um dos formatos com a mesma estrutura de consulta.
  2. Verifique tudo o que os padrões exigem: a assinatura e a cadeia de confiança do emissor, os digests de cada item divulgado, a validade ou o status e a vinculação ao portador com o seu próprio nonce e público-alvo; sem a verificação da vinculação, a credencial vira um bearer token que qualquer pessoa pode reutilizar.
  3. Fixe as versões dos rascunhos: registre quais rascunhos do SD-JWT VC e da lista de status você implementa e acompanhe as mudanças até que se tornem RFCs.
  4. Solicite o mínimo: peça age_over_18 em vez da data de nascimento quando essa for a pergunta; os dois formatos oferecem suporte a isso.
  5. Planeje a gestão de certificados: verificadores mdoc precisam das listas de confiança nacionais para as raízes IACA; verificadores SD-JWT VC precisam dos metadados do emissor ou da validação de certificados.

Referências

Todas as comparações