选 SD-JWT VC 还是 mdoc,是数字身份钱包、移动驾驶证和欧盟数字身份钱包背后的格式问题。SD-JWT(RFC 9901)是一种 JWT:其中的声明被替换为加盐摘要,对应的值作为单独的披露项随附发送;SD-JWT VC 在此基础上增加了凭证类型和颁发者密钥发现。mdoc(ISO/IEC 18013-5)是一种 CBOR 文档:各数据元素的摘要汇总在一个经 COSE 签名的移动安全对象(Mobile Security Object,MSO)中。两者都只披露持有者选择的属性,也都能证明出示者持有设备密钥。
概览
| SD-JWT VC | mdoc(ISO/IEC 18013-5) | |
|---|---|---|
| 核心规范 | RFC 9901 SD-JWT(2025年11月);SD-JWT VC(IETF 草案,已提交 IESG) | ISO/IEC 18013-5:2021;在线出示见 ISO/IEC TS 18013-7:2025 |
| 编码 | JSON、base64url、以 ~ 分隔的披露项 | CBOR(RFC 8949) |
| 颁发者签名 | JWS,通常为 ES256 | 对移动安全对象的 COSE_Sign1 签名 |
| 选择性披露 | 每个声明一个加盐摘要,位于 _sd 中;支持嵌套对象和数组元素 | 每个数据元素一个加盐摘要,按命名空间组织;扁平的元素列表 |
| 持有者绑定 | 密钥位于 cnf;密钥绑定 JWT 包含 aud、nonce、sd_hash | 设备密钥位于 MSO 中;对会话记录的设备签名或 MAC |
| 凭证类型 | 带类型元数据的 vct URI | docType,例如 org.iso.18013.5.1.mDL |
| 颁发者密钥发现 | .well-known/jwt-vc-issuer、x5c 或 DID | 带 IACA 根证书的 x5chain(国家信任列表) |
| 吊销 | 令牌状态列表(IETF 草案,已进入 RFC Editor 队列) | 短有效期;较新的规范中使用状态列表 |
| 现场出示 | 未定义 | NFC、BLE 和 QR 码设备接入(device engagement) |
| 在线出示 | OpenID4VP 1.0(dc+sd-jwt) | OpenID4VP 1.0(mso_mdoc)、ISO 18013-7 附录 C、Digital Credentials API |
| 下文实测:签发时;出示五个声明之一时 | 1,112 字节;1,159 字节 | 1,046 字节;895 字节(base64url 编码后为 1,194) |
两者的选择性披露如何实现
在 SD-JWT 中,颁发者把每个声明转换为一个披露项,即 [salt, name, value] 的 base64url 编码,并且只对 JWT 的 _sd 数组中的 SHA-256 摘要签名。持有者发送 JWT 和自己选择的披露项,用 ~ 分隔。验证者对每个披露项计算哈希,并检查摘要是否在 _sd 中;没有披露项的声明保持隐藏,而盐值使人无法从摘要猜出这些声明。由持有者密钥签名的密钥绑定 JWT(typ: kb+jwt)携带验证者的 aud 和 nonce,以及针对这份出示数据本身计算的 sd_hash。
在 mdoc 中,每个数据元素都是一个 IssuerSignedItem(摘要 ID、随机盐值、标识符、值),以 CBOR 编码并用标签 24 包装。移动安全对象按命名空间列出 SHA-256 摘要、设备公钥和有效期,颁发者用 COSE 对它签名。持有者只返回选中的条目和颁发者签名的 MSO,外加一个对会话记录的设备签名;对于 OpenID4VP,这份会话记录包含验证者的客户端 ID、nonce 和响应 URI 的哈希。
下面的脚本用同样的五个属性构建两种格式,用 P-256 密钥签名,并只出示 age_over_18。它会先对照 RFC 9901 §4.2.3 中的示例检查 SD-JWT 摘要的计算(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)
摘要与 RFC 9901 中给出的值一致。这些大小来自一个不含颁发者证书的最小示例:真实凭证还会加上 x5c 或 x5chain 证书链(通常为 1~2 KB)、状态信息和更多属性,这对两种格式的影响大致相同。不过规律不变。SD-JWT 出示数据是包含全部摘要的完整颁发者 JWT,加上一个披露项和一个密钥绑定 JWT;而 mdoc 出示数据会去掉未选中的条目,同时保留 MSO,因此隐藏的属性越多,mdoc 出示数据就越小。
两者各自用在哪里
《欧洲数字身份条例》要求各成员国在2026年12月前提供钱包,并要求必须采用强用户身份验证的私营依赖方(例如银行)在2027年12月前接受这些钱包;架构与参考框架(ARF)要求提供方同时以 mdoc 和 SD-JWT VC 两种格式签发个人身份识别数据。在全球范围内,移动驾驶证都遵循 ISO/IEC 18013-5,美国 TSA 和美国许多州在检查点接受移动驾驶证。
在 Web 端,Chrome 141 和 Safari 26 于2025年推出了 W3C Digital Credentials API,网页可以借助它向平台钱包请求凭证。Chrome 接受 OpenID4VP 请求,因此两种格式都可以请求;Safari 只支持 ISO 18013-7 附录 C 协议(org-iso-mdoc),因此想要覆盖 iPhone 用户的 Web 验证者必须接受 mdoc。OpenID4VP 1.0 自2025年7月起成为正式版,它定义了 dc+sd-jwt 和 mso_mdoc 两种格式,以及适用于两者的 DCQL 查询语言。
对实现者而言的实际差异
- 工具链:SD-JWT 基于 JSON 和 JOSE,因此任何 JWT 库再加上大约一百行处理披露项的代码就够了;SD-JWT 查看器可以解码它们。mdoc 需要 CBOR、COSE 和 ISO 数据模型;mdoc 查看器和 COSE 解码器可以显示这些结构。
- 数据模型:SD-JWT 支持嵌套对象和可单独披露的数组元素;mdoc 使用扁平的命名空间来组织元素,标识符由 ISO 定义,例如
age_over_18。 - 不可关联性:两者在多次出示中都会重复使用相同的颁发者签名和摘要,因此串通的验证者可以把这些出示关联起来。钱包的缓解办法是批量签发凭证,每份凭证只使用一次。
- 信任:mdoc 依靠一直追溯到国家根证书(IACA)的颁发者证书链,这适合政府颁发者。SD-JWT VC 则既支持基于 Web 的密钥发现,也支持 X.509。
- 标准化状态:RFC 9901、ISO/IEC 18013-5 和 OpenID4VP 1.0 已是正式版。SD-JWT VC 和令牌状态列表是处于最后阶段的 IETF 草案(截至2026年10月),因此要固定你所实现的草案版本。
如何选择
- 政府身份证件、驾驶证、现场年龄核验:mdoc。
- 目前就必须在 iPhone 上可用的 Web 验证:通过 Digital Credentials API 使用 mdoc。
- EUDI 钱包的个人身份识别数据:两者都要,这是参考框架的要求。
- 在线签发的学位证书、职业资格证、员工凭证或会员凭证:SD-JWT VC。
- 已经基于 OAuth 和 OpenID Connect 构建的系统:通过 OpenID4VCI 和 OpenID4VP 使用 SD-JWT VC。
- 不需要选择性披露的已签名 JSON 声明:普通 JWT 或可验证凭证就够了。
迁移说明
- 实现 OpenID4VP 1.0 并使用 DCQL:它用相同的查询结构请求任一种格式。
- 验证标准要求的所有内容:颁发者签名和信任链、每个已披露条目的摘要、有效期或状态,以及对照你自己的 nonce 和受众检查持有者绑定;跳过绑定检查,凭证就成了任何人都能重放的 Bearer 令牌。
- 固定草案版本:记录你实现的是哪一版 SD-JWT VC 草案和状态列表草案,并跟踪变更,直到它们成为 RFC。
- 只请求最少的数据:如果只需要确认是否年满 18 岁,就请求
age_over_18,而不是出生日期;两种格式都支持这种做法。 - 规划证书管理:mdoc 验证者需要国家信任列表来获取 IACA 根证书;SD-JWT VC 验证者需要颁发者元数据或证书验证。