简短回答:SD-JWT(RFC 9901,2025年11月)把已签名 JWT 中每个可隐藏的声明替换为一个披露项(disclosure)的哈希。披露项是一个经 base64url 编码的小型 JSON 数组,包含随机盐、声明名称和声明值。颁发者对这些哈希签名;之后,持有者发送已签名的 JWT,并只附上自己选择的披露项,彼此用 ~ 连接。验证者重新计算哈希,并检查每个哈希都出现在已签名的载荷中。密钥绑定 JWT(typ: kb+jwt)由凭证中指定的密钥签名,它把一次出示绑定到一个验证者(aud)、一次请求(nonce)以及恰好这几个披露项(sd_hash)。SD-JWT VC 在此之上增加了凭证层:typ: dc+sd-jwt、写在 vct 中的凭证类型、状态以及类型元数据。SD-JWT VC 目前仍是互联网草案(Internet-Draft),但它已是每个欧盟数字身份钱包都必须支持的两种凭证格式之一,另一种是 ISO/IEC 18013-5 mdoc。
披露项与摘要
对象属性的披露项是数组 [salt, name, value];数组元素的披露项是 [salt, value](RFC 9901 §4.2)。披露项是经 base64url 编码的 UTF-8 JSON,而摘要是这个编码后的字符串(而不是 JSON)的 SHA-256 哈希,再经 base64url 编码(§4.2.3)。由于哈希覆盖的是颁发者选定的确切字节,因此不需要 JSON 规范化:同一个声明用不同的空白字符编码,就是另一个披露项,摘要也随之不同。下面用 RFC 自己的示例验证这两点:
RFC 9901 §4.2.3 digest: X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
same claim, no spaces: TZjouOTrBKEwUNjNDs9yeMzBoQn8FFLPaJjRRmAtwrM
颁发者把摘要放进载荷中的 _sd 数组,并对其排序或打乱,使顺序不透露任何信息;颁发者还可以加入不对应任何披露项的诱饵摘要(§4.2.5),以免泄露隐藏声明的数量。_sd_alg 指明哈希算法;SHA-256 是默认算法,也是必须实现的算法。盐使摘要无法被猜出:如果没有盐,验证者可以对每个可能的日期计算形如 ["birthdate", "1984-01-26"] 的数组的哈希,从而找到匹配项。RFC 9901 建议每个盐至少包含 128 位随机值,并为每个声明独立生成(§9.3)。
在传输中,SD-JWT 的形式是 <issuer-signed JWT>~<disclosure>~…~<disclosure>~;带密钥绑定的出示数据则把最后那个空的部分换成密钥绑定 JWT:…~<disclosure>~<KB-JWT>(§4)。验证者正是靠末尾的 ~ 区分这两种形式。
用 76 行代码实现颁发者、钱包和验证者
下面的模块实现的是扁平的情形:每个可隐藏的声明都是顶层属性。这已足以看清每条规则如何起作用。嵌套披露和递归披露在内层对象上执行同样的步骤(§6)。
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);
验证者的各条规则与 RFC 9901 §7.1 和 §7.3 一一对应:拒绝 none;检查颁发者及其密钥;确认支持 _sd_alg 指定的算法;让每个披露项恰好对应一个摘要;拒绝名为 _sd 或 ... 的披露项,以及与可见声明同名的披露项;拒绝出现两次的摘要和没有被任何地方引用的披露项;最后用 cnf 中的密钥验证密钥绑定 JWT 的签名,并检查它的 typ、时效、aud、nonce 和 sd_hash。注意 §7.3 的第 1 步:是否要求密钥绑定由验证者的策略决定,绝不取决于持有者是否碰巧发送了 KB-JWT。
运行结果与交叉检验
驱动脚本签发一份身份凭证,其中有五个可隐藏的声明(名字、姓氏、出生日期、地址和年龄阈值),只向一家网店出示年龄阈值,用上面的模块和 OpenWallet Foundation 的 @sd-jwt/sd-jwt-vc 0.23.0 分别验证这份出示数据,然后用六种篡改方式测试两者:
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
两个验证器在每种情况下的结论都一致。逐行来看:
- 选择性披露有效。网店收到了
age_equal_or_over;在库的结果中,given_name为undefined,因为它的披露项根本没有发送。两个验证器重建出的声明完全相同。 - 持有者无法修改声明。改动年龄披露项后产生的摘要,颁发者从未签过名。
- 一份出示数据只对一个验证者和一次请求有效。把它重放给另一个受众,或用旧的 nonce 回应新的请求,验证都会失败。
sd_hash锁定了所选的披露项。钱包为密钥绑定 JWT 签名之后再追加披露项,验证就会失败。- 没有钱包的密钥,凭证就毫无用处,前提是验证者要求密钥绑定:被删除的密钥绑定 JWT 和用另一把密钥重新签名的密钥绑定 JWT 都被拒绝了。
这次运行还揭示了一点,它不是一项检查,而是设计上的教训:网店只问了“是否年满 18 岁”,却同时得知了“未满 65 岁”,因为颁发者把所有阈值都打包进了同一个披露项。粒度在签发时就已确定。RFC 9901 §6.2 以 address 对象为例展示了结构化形式:每个属性各有自己的披露项,而该对象保留一个 _sd 数组,其中每个属性各有一个摘要。如果按这种方式签发,age_equal_or_over 就能让钱包只披露 "18": true。
选择性披露隐藏不了什么
选择性披露隐藏的是值,而不是凭证本身。同一个 SD-JWT 的每次出示都带着相同的颁发者签名、相同的 _sd 摘要和相同的 cnf 密钥,因此两个验证者只要互相核对,就能判断出他们看到的是同一份凭证,即使其中一个只收到了年龄,另一个只收到了邮政编码。RFC 9901 §10.1 对此说得很直白:这种格式“不满足匿名凭证的安全属性”,而从验证者那里拿回出示数据的颁发者,总能认出自己签发的凭证。mdoc 也是如此,因为它采用的是同样的加盐哈希方法。
RFC 描述的缓解措施是批量签发(batch issuance):颁发者向钱包签发多份声明相同的凭证,钱包对每个验证者、甚至每个会话使用不同的一份。要做到这一点,批次中的每份凭证都必须有自己的盐和自己的密钥绑定密钥,而 iat、exp 这类时间声明必须随机化或取整,因为精确的时间戳和序列号一样可以用来关联凭证。批量签发能防止验证者之间相互关联出示数据,但无法对颁发者本身隐藏出示数据。状态检查是与此相关的另一处泄露:验证者就某一份凭证向颁发者查询,就等于告诉颁发者这份凭证在哪里被使用。正因如此,令牌状态列表让验证者下载一份覆盖大量凭证的压缩列表,而不是只查询某一份凭证。
SD-JWT VC 增加了什么
SD-JWT 只是一个容器。基于 SD-JWT 的可验证凭证规定了如何用它来承载凭证:
typ: dc+sd-jwt,媒体类型为application/dc+sd-jwt。在 -06 版之前,草案使用的是vc+sd-jwt;后来为了避开 W3C 可验证凭证数据模型所用的vc这一名称而改名。接触过早期实现的验证者仍可能见到vc+sd-jwt。vct,必填:凭证类型,是一个抗碰撞的字符串。类型可以带有机器可读的类型元数据,用于描述 schema 和显示方式。iss、cnf(持有者密钥,使用密钥绑定时必填)、status(通过令牌状态列表实现吊销)以及有效期声明。这些声明和vct一样,不得设为可选择性披露:验证者必须始终能看到凭证由谁签发、绑定给谁,以及是否仍然有效。
截至2026年10月20日的状态:SD-JWT VC 处于草案 -19(2026年8月31日),已提交给 IESG,尚未成为 RFC。它用于吊销的令牌状态列表已获批准,正在 RFC Editor 队列中。RFC 9901 本身已经发布。
欧盟数字身份钱包
修订后的 eIDAS 条例(欧盟第 2024/1183 号条例)要求每个成员国在该条例的核心实施法案生效后 24 个月内,至少提供一个欧洲数字身份钱包。这些实施法案(包括关于个人身份识别数据的欧盟第 2024/2977 号实施条例)于2024年12月4日发布,2024年12月24日生效,因此截止日期为2026年12月24日。
技术层面由《架构与参考框架》(ARF)描述,其 3.0.0 版于2026年7月23日发布(ARF 本身只是资料性文件,具有约束力的是实施法案)。ARF 第 5 章指出:
- 钱包支持两种凭证格式:ISO/IEC 18013-5 mdoc 和 SD-JWT VC。W3C VCDM 2.0 是可选格式,且仅用于非合格的属性证明。
- mdoc 凭证在近距离场景下通过 ISO/IEC 18013-5 出示,在远程场景下通过 ISO/IEC 18013-7 或配合 HAIP 规范的 OpenID for Verifiable Presentations 出示。SD-JWT VC 凭证只能通过 OpenID4VP 和 HAIP 远程出示,“不能用于近距离出示”。
- 个人身份识别数据(PID)使用 mdoc 文档类型
eu.europa.ec.eudi.pid.1和 SD-JWT VC 类型urn:eudi:pid:1,各国的类型(例如urn:eudi:pid:de:1)位于同一命名空间中(PID 规则手册)。
围绕钱包的 OpenID 协议均已定稿:OpenID4VP 1.0(2025年7月)、OpenID4VCI 1.0(2025年9月)和 HAIP 1.0(2025年12月);HAIP 为高保障场景规定了 SD-JWT VC 和 mdoc 的使用方式。
SD-JWT VC 与 mdoc 对比
ARF 称这两种选择性披露机制“在概念上完全相同”:两者都对单个属性的加盐摘要签名。区别在于它们周围的一切。mdoc 一列使用的是 ISO 标准中的结构名称,而该标准不能免费获取;开源库 @auth0/mdl 在其类型定义中使用了相同的名称。
| SD-JWT VC | ISO/IEC 18013-5 mdoc | |
|---|---|---|
| 编码 | JSON、JWS、base64url | CBOR、COSE |
| 隐藏的属性 | 披露项 [salt, name, value] | 含 random、digestID、elementIdentifier、elementValue 的 IssuerSignedItem |
| 摘要所在位置 | 颁发者签名的 JWT 中的 _sd 数组 | 由颁发者签名的移动安全对象(MSO)中按命名空间存放的 valueDigests |
| 持有者绑定 | 密钥绑定 JWT,在格式中是可选的 | MSO 中的设备密钥和设备认证,强制要求 |
| 对颁发者的信任 | 颁发者密钥,例如通过 x5c 或颁发者元数据获得 | 通往签发机构的颁发者证书链 |
| 在 EUDI 钱包中的出示方式 | 仅远程(OpenID4VP + HAIP) | 近距离(NFC、BLE、QR)和远程 |
| 出处 | IETF OAuth 工作组 | 移动驾驶证标准 |
对验证者来说,支持这种钱包就意味着两种格式都要接受,实际上也就是每种格式各用一个库:mdoc 解析和 COSE 验证都不适合自己手写。
常见错误
- 对解码后的 JSON 而不是 base64url 字符串计算哈希。所有摘要都会对不上;上面的 RFC 示例就是检验方法。
- 盐太短或被重复使用。这会让隐藏的值能被猜出,或在不同声明之间被关联起来。
- 把缺少 KB-JWT 当作“不需要密钥绑定”。由验证者的策略决定,而不是由出示数据决定。
- 不检查密钥绑定 JWT 中的
aud和nonce。这样,持有者以往做过的任何一次出示都可能被重放给你。 - 披露项粒度太粗。每个对象只用一个披露项,就会暴露整个对象;应按验证者将会提出的问题来设计结构。
- 忽略
status。凭证可能签名无误、尚未过期,却已被吊销。
来源
- RFC 9901:JSON Web Token 的选择性披露,§4、§6、§7、§9
- draft-ietf-oauth-sd-jwt-vc:基于 SD-JWT 的可验证凭证,-19
- draft-ietf-oauth-status-list:令牌状态列表
- 欧盟第 2024/1183 号条例和欧盟第 2024/2977 号实施条例
- EUDI 钱包架构与参考框架,v3.0.0,第 5 章;PID 规则手册
- OpenID4VP 1.0、OpenID4VCI 1.0、HAIP 1.0
- @sd-jwt/sd-jwt-vc 和 @auth0/mdl