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 VC | mdoc (ISO/IEC 18013-5) | |
|---|---|---|
| Especificação principal | RFC 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ção | JSON, base64url, disclosures separadas por ~ | CBOR (RFC 8949) |
| Assinatura do emissor | JWS, normalmente ES256 | COSE_Sign1 sobre o Mobile Security Object |
| Divulgação seletiva | Digest com salt por claim em _sd; objetos aninhados e elementos de arrays | Digest com salt por elemento de dados, por namespace; listas planas de elementos |
| Vinculação ao portador | Chave em cnf; JWT de vinculação de chave com aud, nonce, sd_hash | Chave do dispositivo no MSO; assinatura ou MAC do dispositivo sobre a transcrição da sessão |
| Tipo de credencial | URI vct com metadados de tipo | docType, por exemplo org.iso.18013.5.1.mDL |
| Descoberta da chave do emissor | .well-known/jwt-vc-issuer, x5c ou DID | x5chain com uma raiz IACA (listas de confiança nacionais) |
| Revogação | Token Status List (rascunho do IETF na fila do RFC Editor) | Validade curta, listas de status em perfis mais recentes |
| Apresentação presencial | Não definida | Engajamento do dispositivo (device engagement) por NFC, BLE e QR code |
| Apresentação online | OpenID4VP 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 claims | 1.112 bytes; 1.159 bytes | 1.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).
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)`);
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
- Implemente o OpenID4VP 1.0 com DCQL, que solicita qualquer um dos formatos com a mesma estrutura de consulta.
- 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.
- 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.
- Solicite o mínimo: peça
age_over_18em vez da data de nascimento quando essa for a pergunta; os dois formatos oferecem suporte a isso. - 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
- RFC 9901, divulgação seletiva para JWTs
- Credenciais verificáveis baseadas em SD-JWT (rascunho do IETF)
- Token Status List (rascunho do IETF)
- ISO/IEC 18013-5:2021, habilitação digital (mobile driving licence)
- OpenID for Verifiable Presentations 1.0 (apêndice B.2, mdoc; B.3, SD-JWT VC)
- Digital Credentials API do W3C
- Chrome: lançamento da Digital Credentials API
- Architecture and Reference Framework da EUDI Wallet