SD-JWT-VC 技术原理详解

文档版本:v1.0
编写日期:2026-04-14
读者定位:有基本编程背景,不熟悉 Web 身份标准的开发者或产品负责人


目录

  1. W3C 是什么,凭证为何"符合 W3C 标准"
  2. JWT 基础:先理解积木,再理解房子
  3. SD-JWT:给 JWT 加上"选择性披露"能力
  4. 完整生命周期:从签发到验证
  5. 名词对照:报告中各部分是什么关系
  6. 手工推演:用一个字段走完整个流程

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 IntegritySD-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 里没有任何字段的明文值kycStatusfullNamedateOfBirth 这些字段的值都不在这里,这里只有它们的哈希值(存在 _sd 数组里)。这就是 SD-JWT 的核心机制,稍后详解。

Signature(签名)

用 zkMe 的 Ed25519 私钥Header + "." + Payload 计算出的签名。

签名的作用:任何人用 zkMe 的公钥都能验证这个签名,从而证明:

  1. 这个 token 确实是 zkMe 签发的(而不是伪造的)
  2. 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>

完整格式图:

SD-JWT-VC 完整字符串

~

~

~

~

JWT 部分
Header.Payload.Signature

Disclosure 1
kycStatus

Disclosure 2
documentType

Disclosure 3
issuingStateCode

...更多字段

实际字符串示例(换行仅为可读性):

eyJhbGciOiJFZERTQSIsInR5cCI6InZjK3NkLWp3dCIsImtpZCI6ImRpZDp3ZWI6emsubWUja2V5LTEifQ
.eyJpc3MiOiJodHRwczovL3prLm1lIiwiaWF0IjoxNzQ0NTAyNDAwLCJleHAiOjE3NzYwMzg0MDAuLi59
.AKkaBl9UcJCnHxgV7v_KY_cSampleSignatureXXXXXXXXXXXXXXXXXXXXXXXXXXX
~WyI0dDVSdld4OFl6MEFiMUNkMkVmM0doNCIsImt5Y1N0YXR1cyIsInZlcmlmaWVkIl0
~WyI1dTZTdld4OVl6MEFiMUNkMkVmM0doNSIsImRvY3VtZW50VHlwZSIsMl0
~WyI2djdUd1h5MFphMUJjMkRlM0ZnNEhpNiIsImlzc3VpbmdTdGF0ZUNvZGUiLCJDSE4iXQ
...

4. 完整生命周期:从签发到验证

第三方(Verifier 验证方) 用户(Holder 持有人) zkMe(Issuer 签发方) 第三方(Verifier 验证方) 用户(Holder 持有人) zkMe(Issuer 签发方) 用户完成 KYC,DocumentScanResult 已生成 用户保存凭证字符串(存在手机/文件均可) 公钥缓存后,后续验证完全离线 为每个字段生成随机盐值 计算每个字段的 Disclosure 字符串 对每个 Disclosure 计算 SHA-256 哈希 将所有哈希写入 JWT Payload 的 _sd 数组 用 Ed25519 私钥对 JWT 签名 返回完整 SD-JWT-VC 字符串(JWT + 所有 9 个 Disclosure) 决定向第三方出示哪些字段 删掉不想出示的 Disclosure,只保留选中的 发送精简版凭证(JWT + 选中的 N 个 Disclosure) 解析 JWT Header,取得 kid = "key-1" GET https://zk.me/.well-known/did.json(首次,取公钥) 用公钥验证 JWT 签名 检查 exp(是否过期) 对收到的每个 Disclosure 重算 SHA-256 将重算哈希与 JWT Payload 的 _sd 数组对比 哈希匹配 → 字段值真实可信

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 字符串等概念,它们的关系如下:

完整 SD-JWT-VC 字符串

JWT 部分

.

.

~

~

~

~

JWT Header
算法 + kid
(base64url)

JWT Payload
_sd 哈希数组 + 元数据
(base64url)

JWT Signature
私钥签名
(base64url)

Disclosure 1
[salt, kycStatus, verified]

Disclosure 2
[salt, documentType, 2]

Disclosure 3
[salt, issuingStateCode, CHN]

Disclosure ...

名词 是什么 在哪里 包含什么
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 不能换成其他值
  盐值        → 哈希无法被暴力枚举反推
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐