SD-JWT-VC 技术原理详解
SD-JWT-VC 技术原理详解
文档版本:v1.0
编写日期:2026-04-14
读者定位:有基本编程背景,不熟悉 Web 身份标准的开发者或产品负责人
目录
- W3C 是什么,凭证为何"符合 W3C 标准"
- JWT 基础:先理解积木,再理解房子
- SD-JWT:给 JWT 加上"选择性披露"能力
- 完整生命周期:从签发到验证
- 名词对照:报告中各部分是什么关系
- 手工推演:用一个字段走完整个流程
1. W3C 是什么,凭证为何"符合 W3C 标准"
1.1 W3C 是什么
W3C(World Wide Web Consortium,万维网联盟)是制定 Web 技术国际标准的机构,成立于 1994 年。你每天用的很多技术都是 W3C 定的规范:
- HTML:网页结构标准
- CSS:网页样式标准
- WebAssembly:浏览器运行二进制代码的标准
- Verifiable Credentials(VC):数字可验证凭证标准 ← 我们用的
W3C 不写代码,只写规范文档(Spec),规范里说"一个合格的凭证必须包含哪些字段、格式是什么、签名要怎么验证"。只要你的实现满足这个规范,就叫做"符合 W3C 标准"。
1.2 W3C VC 2.0 规定了什么
W3C VC 2.0(Verifiable Credentials Data Model 2.0,2025 年 5 月正式发布)的核心是规定一个"可验证凭证"必须:
| 要素 | 规定内容 |
|---|---|
| 三个角色 | 必须有 Issuer(签发方)、Holder(持有人)、Verifier(验证方) |
| 必选字段 | @context(声明标准版本)、type(凭证类型)、issuer(签发方标识)、credentialSubject(凭证内容) |
| 时效字段 | validFrom / validUntil(可选,但强烈建议) |
| 唯一标识 | id(凭证唯一 ID,防止重放) |
| 签名机制 | 规范本身不规定具体签名算法,但认可若干签名套件 |
| 序列化格式 | 认可两种:JSON-LD + Data Integrity 和 SD-JWT-VC(我们用的) |
W3C VC 规范的关键价值是:任何组织、任何语言的实现,只要符合这个规范,生成的凭证就能被所有同样遵循规范的库验证。这就是"标准"的意义——互操作性。
1.3 SD-JWT-VC 如何"符合 W3C"
SD-JWT-VC 是将 IETF 的 SD-JWT 技术(RFC 9901)与 W3C VC 规范结合的格式。W3C VC 2.0 规范明确将其列为认可的凭证序列化格式之一。
具体来说,我们生成的 SD-JWT-VC 字符串满足以下 W3C 要求:
iss = "https://zk.me" → W3C 要求有 issuer
sub = "did:zkMe:0xabc..." → W3C 要求有 credentialSubject.id
vct = "https://schema.zk.me/kyc/v1#KYCCredential" → W3C 要求有 type
iat/exp → W3C 要求有时效信息
签名 → W3C 要求内容不可篡改
2. JWT 基础:先理解积木,再理解房子
SD-JWT-VC 建立在 JWT 之上。不理解 JWT 就无法理解 SD-JWT-VC,就像不理解砖头就无法理解楼。
2.1 JWT 是什么
JWT(JSON Web Token)是一种用于安全传递信息的字符串格式,由三段组成,用 . 分隔:
Header.Payload.Signature
三段都是 base64url 编码的,看起来像乱码,但解码后都是普通的 JSON。
base64url 是什么:一种把任意数据编码成纯 ASCII 字符串的方式,和 base64 类似但把 + 换成 -,/ 换成 _,并去掉末尾的 =,目的是让字符串能安全地放在 URL 里。
2.2 JWT 三段各是什么
Header(头部)
声明"这个 token 使用什么算法签名"以及"用哪把密钥签的"。
{
"alg": "EdDSA",
"typ": "vc+sd-jwt",
"kid": "did:web:zk.me#key-1"
}
| 字段 | 含义 |
|---|---|
alg |
签名算法,EdDSA = Ed25519 椭圆曲线签名 |
typ |
Token 类型,vc+sd-jwt 表明这是一个 SD-JWT-VC |
kid |
Key ID,告诉验证方"去 did:web:zk.me 找名叫 key-1 的那把公钥来验签" |
编码后就是 eyJhbGciOiJFZERTQSIsInR5cCI6InZjK3NkLWp3dCIsImtpZCI6ImRpZDp3ZWI6emsubWUja2V5LTEifQ。
Payload(载荷)
凭证的实际数据内容。
{
"iss": "https://zk.me",
"iat": 1744502400,
"exp": 1776038400,
"vct": "https://schema.zk.me/kyc/v1#KYCCredential",
"sub": "did:zkMe:0xabc123...",
"jti": "urn:uuid:550e8400-...",
"_sd_alg": "sha-256",
"_sd": [
"X9diMvOXfhiMf7YFJyT8kA...",
"aB3kLmN8pQrStUvWxYz0...",
...
]
}
注意:Payload 里没有任何字段的明文值。kycStatus、fullName、dateOfBirth 这些字段的值都不在这里,这里只有它们的哈希值(存在 _sd 数组里)。这就是 SD-JWT 的核心机制,稍后详解。
Signature(签名)
用 zkMe 的 Ed25519 私钥对 Header + "." + Payload 计算出的签名。
签名的作用:任何人用 zkMe 的公钥都能验证这个签名,从而证明:
- 这个 token 确实是 zkMe 签发的(而不是伪造的)
- Header 和 Payload 的内容没有被人改过(一旦改动签名就失效)
2.3 普通 JWT 的完整字符串
eyJhbGciOiJFZERTQSJ9
.
eyJpc3MiOiJodHRwczovL3prLm1lIn0
.
AKkaBl9UcJCnHxgV7v_KY_cSampleSignature
用 . 连接,没有空格和换行(展示时加换行只是为了可读性)。
3. SD-JWT:给 JWT 加上"选择性披露"能力
3.1 普通 JWT 的隐私问题
普通 JWT 中,所有字段都明文写在 Payload 里。签名是对整个 Payload 做的。
这意味着:
- 用户向第三方出示凭证时,所有字段都暴露了——姓名、出生日期、国籍、性别,什么都出示
- 如果用户只想证明"我通过了 KYC",但不想暴露自己的出生日期,做不到
3.2 SD-JWT 的解决思路:把字段值替换成哈希
SD-JWT(Selective Disclosure JWT)的核心思路是:
不把字段值写进 JWT Payload,而是把字段值的哈希写进去。字段的原文单独存放,叫做 Disclosure(披露件)。
这样一来:
- JWT Payload 只有哈希,不暴露任何字段的真实值
- 用户出示时,选择性地附上哪些字段的原文(Disclosure)
- 验证方只能看到用户选择附上的那些字段
3.3 Disclosure 的结构
一个 Disclosure 就是对一个字段的"原始记录",格式固定为包含三个元素的 JSON 数组:
["盐值", "字段名", "字段值"]
例如 kycStatus 字段的 Disclosure(解码后明文):
["4t5RvWx8Yz0Ab1Cd2Ef3Gh4", "kycStatus", "verified"]
| 元素 | 含义 |
|---|---|
"4t5RvWx8Yz0Ab1Cd2Ef3Gh4" |
盐值(随机字符串,防止验证方暴力猜出字段值) |
"kycStatus" |
字段名 |
"verified" |
字段值 |
这个数组经过 JSON 序列化后做 base64url 编码,得到 Disclosure 字符串:
WyI0dDVSdld4OFl6MEFiMUNkMkVmM0doNCIsImt5Y1N0YXR1cyIsInZlcmlmaWVkIl0
盐值的作用:如果没有盐值,验证方可以猜测
kycStatus只有"verified"或"rejected"两种值,直接枚举计算哈希就能还原字段值。盐值是随机的,无法枚举,从而保证哈希不可反推。
3.4 哈希写进 Payload
对上面的 Disclosure 字符串(base64url 编码后的那串)计算 SHA-256 哈希,得到:
X9diMvOXfhiMf7YFJyT8kAqB2cD3eF4gH5iJ6kL7mN8
这个哈希写进 JWT Payload 的 _sd 数组:
"_sd": [
"X9diMvOXfhiMf7YFJyT8kAqB2cD3eF4gH5iJ6kL7mN8", ← kycStatus 的哈希
"aB3kLmN8pQrStUvWxYz0A1bC2dE3fG4hI5jK6lM7nO8", ← documentType 的哈希
...
]
_sd 数组里所有哈希的顺序是随机打乱的,验证方无法从位置猜出对应字段名。
3.5 完整 SD-JWT 字符串格式
SD-JWT 在标准 JWT 字符串后面,用 ~ 分隔符附加 Disclosure:
<JWT Header>.<JWT Payload>.<JWT Signature>~<Disclosure_1>~<Disclosure_2>~...~<Disclosure_N>
完整格式图:
实际字符串示例(换行仅为可读性):
eyJhbGciOiJFZERTQSIsInR5cCI6InZjK3NkLWp3dCIsImtpZCI6ImRpZDp3ZWI6emsubWUja2V5LTEifQ
.eyJpc3MiOiJodHRwczovL3prLm1lIiwiaWF0IjoxNzQ0NTAyNDAwLCJleHAiOjE3NzYwMzg0MDAuLi59
.AKkaBl9UcJCnHxgV7v_KY_cSampleSignatureXXXXXXXXXXXXXXXXXXXXXXXXXXX
~WyI0dDVSdld4OFl6MEFiMUNkMkVmM0doNCIsImt5Y1N0YXR1cyIsInZlcmlmaWVkIl0
~WyI1dTZTdld4OVl6MEFiMUNkMkVmM0doNSIsImRvY3VtZW50VHlwZSIsMl0
~WyI2djdUd1h5MFphMUJjMkRlM0ZnNEhpNiIsImlzc3VpbmdTdGF0ZUNvZGUiLCJDSE4iXQ
...
4. 完整生命周期:从签发到验证
4.1 zkMe 签发凭证(Issuer 视角)
输入:DocumentScanResult(用户 KYC 采集结果)
步骤:
1. 读取 KYC 字段:kycStatus, documentType, issuingStateCode, dateOfBirth,
fullName, givenName, surname, sex, kycVerifiedAt
2. 对每个字段:
a. crypto.randomBytes(16) → 生成 16 字节随机盐值 → base64url 编码
b. 构造 JSON 数组:["盐值", "字段名", 字段值]
c. JSON.stringify → base64url 编码 → 得到 Disclosure 字符串
d. SHA-256(Disclosure 字符串) → 得到哈希
3. 构造 JWT Payload:
{
iss: "https://zk.me",
iat: 当前时间戳,
exp: 当前时间戳 + 有效期,
vct: "https://schema.zk.me/kyc/v1#KYCCredential",
sub: "did:zkMe:" + holderAddress,
jti: "urn:uuid:" + uuid(),
_sd_alg: "sha-256",
_sd: [ 哈希1, 哈希2, ..., 哈希9 ] // 顺序随机打乱
}
4. 构造 JWT Header:{ alg: "EdDSA", typ: "vc+sd-jwt", kid: "did:web:zk.me#key-1" }
5. 用 Ed25519 私钥对 base64url(Header) + "." + base64url(Payload) 签名
6. 组装:Header.Payload.Signature~D1~D2~...~D9
输出:完整 SD-JWT-VC 字符串(包含 JWT + 全部 9 个 Disclosure)
4.2 用户持有凭证(Holder 视角)
用户收到的就是一个普通字符串,形如:
eyJ....eyJ....sig~WyJ...~WyJ...~WyJ...~WyJ...~WyJ...~WyJ...~WyJ...~WyJ...~WyJ...
用户如何"选择性披露":
假设用户只想向某个平台证明"我通过了 KYC,证件类型是护照",不想泄露姓名和出生日期。
用户只需要删掉不想出示的 Disclosure,保留:
eyJ....eyJ....sig~WyJ...kycStatus~WyJ...documentType
↑ 保留这两个 ↑ 删掉其余 7 个
这不会破坏签名,因为签名是对 JWT(Header + Payload)做的,Disclosure 是附加在外面的,移除 Disclosure 不影响 JWT 本身。
4.3 第三方验证(Verifier 视角)
第三方收到用户出示的精简版凭证后,完全在本地完成验证:
步骤 1:以 "~" 拆分字符串,得到 JWT 部分和若干 Disclosure 字符串
步骤 2:解码 JWT Header,取出 kid = "did:web:zk.me#key-1"
步骤 3:查本地缓存是否有该 kid 对应的公钥
- 有缓存 → 直接用
- 无缓存 → GET https://zk.me/.well-known/did.json,取出公钥并缓存
步骤 4:用公钥验证 JWT 签名
- 签名验证失败 → 凭证伪造,拒绝
步骤 5:检查 JWT Payload 中的 exp 字段
- exp < 当前时间 → 凭证过期,拒绝
步骤 6:对每个收到的 Disclosure:
a. base64url 解码 → JSON.parse → 得到 [salt, fieldName, value]
b. 重新计算 SHA-256(该 Disclosure 字符串)
c. 在 JWT Payload 的 _sd 数组中查找该哈希
d. 找到 → 字段名和值真实可信
e. 找不到 → Disclosure 被篡改,拒绝
步骤 7:汇总被验证通过的字段,得出最终结论
第三方能知道什么:只有用户选择出示的字段(如 kycStatus = "verified")。
第三方不知道什么:未出示字段的值(如姓名、出生日期)。_sd 数组里有这些字段的哈希,但哈希是单向的,无法反推出原始值(因为有盐值保护)。
5. 名词对照:报告中各部分是什么关系
可行性报告中出现了 JWT Header、JWT Payload、Disclosure、完整 SD-JWT 字符串等概念,它们的关系如下:
| 名词 | 是什么 | 在哪里 | 包含什么 |
|---|---|---|---|
| JWT Header | 签名算法声明 | 最终字符串的第 1 段(. 之前) |
alg, typ, kid |
| JWT Payload | 凭证核心元数据 | 最终字符串的第 2 段(两个 . 之间) |
iss, sub, exp, _sd 哈希数组 |
| JWT Signature | 对 Header+Payload 的签名 | 最终字符串的第 3 段(第 2 个 . 之后、第 1 个 ~ 之前) |
EdDSA 签名字节 |
| Disclosure | 某个字段的原文记录 | 每个 ~ 之后的一段 |
[盐值, 字段名, 字段值] 的 base64url |
| 完整 SD-JWT 字符串 | 上述所有部分拼在一起 | 就是最终发给用户的那整个字符串 | JWT + 所有 Disclosure |
一句话总结:
JWT Payload 里只有哈希(相当于锁着的保险箱),Disclosure 里是字段原文(相当于钥匙),用户出示时选择带哪些钥匙,第三方只能打开有钥匙的那些箱子。
6. 手工推演:用一个字段走完整个流程
以 kycStatus = "verified" 这一个字段为例,完整演示从签发到验证的每一步。
步骤 1:生成随机盐值
随机生成 16 字节 → base64url 编码 → "4t5RvWx8Yz0Ab1Cd2Ef3Gh4"
(实际由 crypto.randomBytes(16) 生成,每次不同,这里是示例。)
步骤 2:构造 Disclosure 原文数组
["4t5RvWx8Yz0Ab1Cd2Ef3Gh4", "kycStatus", "verified"]
步骤 3:JSON 序列化
JSON.stringify(["4t5RvWx8Yz0Ab1Cd2Ef3Gh4", "kycStatus", "verified"])
= '["4t5RvWx8Yz0Ab1Cd2Ef3Gh4","kycStatus","verified"]'
步骤 4:base64url 编码得到 Disclosure 字符串
base64url('["4t5RvWx8Yz0Ab1Cd2Ef3Gh4","kycStatus","verified"]')
= "WyI0dDVSdld4OFl6MEFiMUNkMkVmM0doNCIsImt5Y1N0YXR1cyIsInZlcmlmaWVkIl0"
这就是最终附在 ~ 后面的 Disclosure 字符串。
步骤 5:对 Disclosure 字符串计算 SHA-256 哈希
SHA-256("WyI0dDVSdld4OFl6MEFiMUNkMkVmM0doNCIsImt5Y1N0YXR1cyIsInZlcmlmaWVkIl0")
= "X9diMvOXfhiMf7YFJyT8kAqB2cD3eF4gH5iJ6kL7mN8"(base64url 表示)
步骤 6:哈希写入 JWT Payload
{
"iss": "https://zk.me",
"iat": 1744502400,
"exp": 1776038400,
"vct": "https://schema.zk.me/kyc/v1#KYCCredential",
"sub": "did:zkMe:0xabc123...",
"jti": "urn:uuid:550e8400-e29b-41d4-a716-446655440000",
"_sd_alg": "sha-256",
"_sd": [
"X9diMvOXfhiMf7YFJyT8kAqB2cD3eF4gH5iJ6kL7mN8",
"... 其他字段的哈希 ..."
]
}
注意:Payload 里完全看不出 kycStatus 这个字段名,也看不出值是 verified,只有一串哈希。
步骤 7:用私钥对 JWT 签名
signData = base64url(Header) + "." + base64url(Payload)
signature = Ed25519_Sign(zkMe私钥, signData)
JWT = signData + "." + base64url(signature)
步骤 8:组装完整 SD-JWT-VC 字符串
完整凭证 = JWT + "~" + Disclosure_kycStatus + "~" + Disclosure_documentType + ...
假设只有一个字段(简化演示):
eyJhbGciOiJFZERTQSJ9.eyJpc3MiOiJodHRwczovL3prLm1lIiwiX3NkIjpbIlg5ZGlNdk9YZmhpTWY3WUZKeVQ4a0FxQjJjRDNlRjRnSDVpSjZrTDdtTjgiXX0.SampleSig
~WyI0dDVSdld4OFl6MEFiMUNkMkVmM0doNCIsImt5Y1N0YXR1cyIsInZlcmlmaWVkIl0
步骤 9:验证方收到后,反向验证
1. 以 "~" 拆分 → JWT 部分 + Disclosure 字符串
2. 验证 JWT 签名(用 zkMe 公钥)→ 通过
3. 检查 exp → 未过期
4. 取出 Disclosure 字符串:
"WyI0dDVSdld4OFl6MEFiMUNkMkVmM0doNCIsImt5Y1N0YXR1cyIsInZlcmlmaWVkIl0"
5. 重新计算 SHA-256("WyI0dDVSdld4OFl6MEFiMUNkMkVmM0doNCIsImt5Y1N0YXR1cyIsInZlcmlmaWVkIl0")
= "X9diMvOXfhiMf7YFJyT8kAqB2cD3eF4gH5iJ6kL7mN8"
6. 在 JWT Payload 的 _sd 数组中查找该哈希 → 找到!
7. base64url 解码 Disclosure → JSON.parse
= ["4t5RvWx8Yz0Ab1Cd2Ef3Gh4", "kycStatus", "verified"]
→ 字段名 = "kycStatus",字段值 = "verified"
8. 结论:该用户的 kycStatus = "verified",且这个值由 zkMe 签名,未被篡改
如果有人篡改了 Disclosure(比如把 verified 改成 false):
篡改后 Disclosure 字符串变了 → SHA-256 哈希变了
→ 在 _sd 数组中找不到该哈希
→ 验证失败,凭证被拒绝
如果有人修改了 JWT Payload 中的哈希:
Payload 改变 → JWT 签名失效
→ 步骤 2 验签失败
→ 整个凭证被拒绝
这就是 SD-JWT-VC 安全性的完整闭环:签名保护 JWT,哈希保护 Disclosure,盐值防止暴力枚举。
附录:快速参考卡
SD-JWT-VC 字符串结构:
┌─────────────────────────────────────────────────────┐
│ 完整凭证字符串 │
├──────────────────────┬────────────────────────────── │
│ JWT 部分 │ Disclosure 部分 │
│ Header.Payload.Sig │ ~D1~D2~D3~D4~D5~D6~D7~D8~D9 │
│ (用 . 分隔) │ (用 ~ 分隔) │
│ │ │
│ Header: 算法/kid │ 每个 Di = base64url( │
│ Payload: _sd哈希数组 │ JSON([salt, name, value]) │
│ Sig: Ed25519签名 │ ) │
└──────────────────────┴───────────────────────────────┘
角色与行为:
Issuer (zkMe) → 生成所有 Disclosure + 哈希 → 签名 JWT → 发给用户
Holder (用户) → 保存完整字符串 → 出示时选择性保留部分 Disclosure
Verifier (第三方) → 验签 JWT → 核验 Disclosure 哈希 → 读出披露字段
安全性保证:
JWT 签名 → 证明 Issuer 身份 + Payload 内容未被篡改
哈希绑定 → Disclosure 不能换成其他值
盐值 → 哈希无法被暴力枚举反推
更多推荐




所有评论(0)