
假设你已经签发了 200 万个可验证凭证,可能是员工工牌、KYC 证明、年龄证明、钱包证明,也就是你的系统签过名、通过网络分发给员工的所有东西。现在有一台设备被攻破了,你需要地球上每一个验证方都在几分钟内拒绝那一个凭证,同时不能暴露被撤销的是哪个凭证,不能要求验证方向你部署的签发方服务器发请求,也不能对撤销列表做 O(n) 扫描。这就是凭证撤销问题。
常规的做法,比如 CRLs 证书吊销列表 和 OCSP 在线证书状态协议,要么泄露隐私,要么无法扩展,要么需要中心化的基础设施,而它会成为单点故障。W3C 在 2015 年标准化了一个新方案,叫作 BitstringStatusList。围绕它构建系统,就能处理数以百万计的凭证、并发撤销、多个权威方各自独立地暂停凭证、为保护隐私而对 VC 随机分配索引,以及一条准备好了再延迟发布的流水线。
可以把位串(bitstring)想象成一个巨大的状态寄存器,每个凭证都会在这个巨大的位串里被分配一个索引。位 0 = 有效,位 1 = 已撤销或已暂停,取决于你的设计。比如要撤销凭证 #94,567,你就把位 94,567 从 0 翻转成 1,要检查它是否被撤销,只需读取同一个位,就这么简单,
Index: 0 1 2 3 4 ... 94567 ... 2097151
Bitstring: 0 0 0 1 0 ... 1 ... 0
↑
credential #94567 is revoked here相当简单的撤销流水线!位串先经过 GZIP 压缩,再以 u 作为 multibase 前缀进行 base64url 编码,然后签名,最后它本身作为一个 Verifiable Credential 发布出去,也就是 BitstringStatusListCredential。验证方拿到它,解压,读取那个位。一次请求,一次位检查,搞定。
这就是它能扩展的原因:无论撤销了多少凭证,位串的大小都是固定的。一个 200 万条目的列表在未压缩状态下是 256 KB,但在通常低于 1% 的撤销率下,GZIP 能把它压缩到 1-3 KB。CDN 几毫秒就能把它送出去。
一个 2,097,152-bit 256 KB 列表的压缩比:
| 填充率 | 置位数 | 压缩后大小 | 压缩比 |
|---|---|---|---|
| 0%(空) | 0 | ~300 字节 | 870:1 |
| 0.1% | 2,097 | ~1.5 KB | 170:1 |
| 1% | 20,971 | ~8 KB | 32:1 |
| 10% | 209,715 | ~90 KB | 2.8:1 |
| 50%(随机) | ~1M | ~256 KB | 1:1 |
在真实世界的撤销率下,200 万个凭证的全部状态不到 8 KB 就能装下。CDN 可以轻松地缓存并分发它。
签发凭证时,你会嵌入一个指向状态列表的 credentialStatus 条目。例如:
{
"@context": [
"https://www.w3.org/ns/credentials/v2",
"https://www.w3.org/ns/credentials/status/v1"
],
"type": ["VerifiableCredential", "EmployeeIDCredential"],
"issuer": "did:polygonid:issuer",
"credentialSubject": { "name": "Alice", "employeeId": "E-1234" },
"credentialStatus": {
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": "94567",
"statusListCredential": "https://status.privado.id/credentials/employee-ids"
}
}每个验证方都会解引用 statusListCredential,解压位串,读取位 94,567,如果是 1,凭证就已被撤销,如果是 0,它就仍然有效。
一些值得了解的 W3C 要求:
statusListIndex 是十进制字符串,而不是整数131,072 个条目,即 16 KB,默认采用这个值是为了群体隐私statusPurpose 必须(MUST)与状态列表凭证中的 statusPurpose 一致RFC 1952 进行 GZIP 压缩,使用前缀 u 进行 multibase 编码,即“base64url,无填充”这是最酷的部分。位串在每个字节内是 big-endian 的,所以索引 0 是字节 0 最左边的那一位,也就是“最高有效位”。
Bit layout within a byte:
Byte index = floor(bit_index / 8)
Bit offset = 7 - (bit_index % 8)
Example: index 0 => byte 0, bit 7 (MSB)
index 7 => byte 0, bit 0 (LSB)
index 8 => byte 1, bit 7 (MSB)用 Rust 实现:
pub fn get_bit(bitstring: &[u8], index: u32) -> bool {
let byte_index = (index / 8) as usize;
let bit_offset = 7 - (index % 8);
(bitstring[byte_index] >> bit_offset) & 1 == 1
}
pub fn set_bit(bitstring: &mut [u8], index: u32) {
let byte_index = (index / 8) as usize;
let bit_offset = 7 - (index % 8);
bitstring[byte_index] |= 1 << bit_offset;
}
pub fn clear_bit(bitstring: &mut [u8], index: u32) {
let byte_index = (index / 8) as usize;
let bit_offset = 7 - (index % 8);
bitstring[byte_index] &= !(1 << bit_offset);
}为什么是 7 - (index % 8)?W3C 规范把索引 0 定义为最左边的位,所以在字节 0b1000_0000 中,从右边的 0 开始数,它是第 7 位。这个减法把规范中的顺序转换成了字节中的顺序。
压缩并编码成 W3C 格式:
use flate2::write::GzEncoder;
use flate2::Compression;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
pub fn compress_and_encode(bitstring: &[u8]) -> String {
// compress
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(bitstring).unwrap();
let compressed = encoder.finish().unwrap();
// encode
format!("u{}", URL_SAFE_NO_PAD.encode(&compressed))
}注意:clear_bit 是让暂停可以恢复的关键。撤销操作绝对不能调用 clear_bit,凭证一旦被撤销,就永久失效了,这一点在服务层强制执行,撤销服务提供了 suspend/unsuspend 接口,却只有 revoke,没有 unrevoke。位串本身与用途无关,撤销的不可逆性是一条业务规则,而不是数据结构的属性
我们亲身经历过这样一种情况:多个服务可能在同一时间撤销凭证。如果没有明确的协调,同一个列表上的两次并发位翻转就会损坏(corrupts)这个 bitstring。典型的丢失更新问题。服务器 A 读取位串并翻转位 100,但服务器 B 在 A 写回之前读取了同一个位串,并翻转了位 200。B 写回,覆盖了 A 的修改,很遗憾,位 100 永远没有被设置上。
我是怎么解决的?悲观行级锁

在 Rust 中使用 sqlx:
pub async fn set_bit_tx(
&self,
tx: &mut Transaction<'_, Postgres>,
list_id: &str,
bit_index: u32,
) -> Result<(bool, i32)> { // get row lock, concurrent txns block here
let record = sqlx::query_as::<_, StatusListRecord>(
"SELECT * FROM status_lists WHERE list_id = $1 FOR UPDATE"
)
.bind(list_id)
.fetch_optional(&mut **tx)
.await?
.ok_or(Error::NotFound)?;
let already_set = get_bit(&record.bitstring, bit_index);
if already_set {
return Ok((true, record.total_revoked)); // idemp
}
let mut bitstring = record.bitstring.clone();
set_bit(&mut bitstring, bit_index);
sqlx::query(
"UPDATE status_lists SET bitstring = $2, total_revoked = total_revoked + 1,
updated_at = NOW() WHERE list_id = $1"
)
.bind(list_id)
.bind(&bitstring)
.execute(&mut **tx)
.await?;
Ok((false, record.total_revoked + 1))
}这里发生了什么:
SELECT ... FOR UPDATE 获取行级锁,并发事务会在这里阻塞算一下:7 种凭证类型 × 3 种用途,就是 21 个状态列表,所以不同凭证类型上的并发撤销永远不会相互争抢。同一个列表上的撤销通过 FOR UPDATE 串行化。锁的持有时间不到 5ms,“一次读、一次写、一次日志插入”,在每秒 100 次撤销的情况下,队列深度保持在 1 以下。
不过 I/O 上的取舍是真实存在的。每次撤销都要从 Psql 读取 256 KB,翻转一个位,再写回 256 KB,外加 WAL。算下来每次撤销大约有 ~768 KB 的 I/O。按每分钟 1,000 次撤销计算,就是 ~750 MB/minute。在现代 SSD 上 Psql 处理起来没问题,但这是扩展时面临的主要挑战。为了更高的吞吐量,我们的做法是批量翻转位,把撤销请求累积在一个队列里,然后在一次读取、修改、写入的流程中统一应用。
如果你按顺序分配索引,“凭证 1 => 索引 0,凭证 2 => 索引 1,依此类推”,那么一个留心的观察者只要知道两个凭证的索引,就能推断出它们的签发顺序,以及它们之间大约签发了多少个凭证。索引应当(SHOULD)随机分配,永远不要按顺序(SEQUENTIALLY)分配。
从更技术的角度来看,这里的解决方案是一个带密钥的 Feistel 置换密码,它把顺序计数器映射为伪随机索引。它是一个双射,100% 确定,同一个计数器总是映射到同一个索引,并且有密钥就能逆向还原
Counter: 0 1 2 3 4 5
↓ Feistel permutation
Index: 847 23 1902 556 12 2041一个 6 轮的 Feistel 网络,轮函数使用 SHA-256:
const FEISTEL_ROUNDS: u8 = 6;
pub fn permute_index(counter: u32, size: u32, key: &[u8]) -> u32 {
let k = size.trailing_zeros();
let half_a = (k + 1) / 2;
let half_b = k / 2;
let mut left = counter >> half_b;
let mut right = counter & ((1u32 << half_b) - 1);
let (mut left_bits, mut right_bits) = (half_a, half_b);
for round in 0..FEISTEL_ROUNDS {
let f = sha256_round(key, round, right) & ((1u32 << left_bits) - 1);
let new_right = left ^ f;
left = right;
right = new_right;
std::mem::swap(&mut left_bits, &mut right_bits);
}
(left << right_bits) | right
}为什么这行得通:
≥4 rounds 的平衡 Feistel 网络可以证明的性质SHA-256(master_secret || list_id || "index-permutation")6× SHA-256,~18μs所以,拿到两个凭证的黑客或攻击者,无法确定它们之间签发了多少个凭证,也无法确定哪个是第一个、第二个或第三个签发的。无论目前已经分配了多少个索引,置换始终在完整的 2^21(2,097,152)空间上进行
单个撤销列表是不够的。每个列表有不同的 statusPurpose,在生产环境中你实际上需要三个独立的状态维度,

| 场景 | 谁来操作 | 哪个列表 | 是否可逆? |
|---|---|---|---|
| 员工被解雇 | 签发方 | revocation | 否,凭证永久失效 |
| 欺诈调查进行中 | 签发方 | suspension “issuer” | 是,拿出证据洗清嫌疑后由签发方解除暂停 |
| 用户报告手机丢失 | 持有者 | suspension “holder” | 是,重新注册后由持有者在新设备上解除暂停 |
你看到的每个列表都是一个独立的 bitstring,有独立的签名权威方,下面就是让这一切运转起来的数据库表结构
CREATE TABLE status_lists (
list_id TEXT PRIMARY KEY,
credential_type TEXT NOT NULL,
status_purpose TEXT NOT NULL,
list_authority TEXT NOT NULL,
bitstring BYTEA NOT NULL,
next_index INTEGER NOT NULL DEFAULT 0,
total_issued INTEGER NOT NULL DEFAULT 0,
total_revoked INTEGER NOT NULL DEFAULT 0,
sequence_number INTEGER NOT NULL DEFAULT 1,
CONSTRAINT uq_type_purpose_authority_seq
UNIQUE (credential_type, status_purpose, list_authority, sequence_number)
);这家伙确保每个 (credential_type, purpose, authority, sequence) 组合恰好只有一个活跃列表
签发凭证时进行三重分配,为单个凭证分配三个独立的索引,三者也都会出现在凭证的 credentialStatus 数组里
let rev = allocate_index(cred_type, cred_id, "revocation", "issuer").await?;
let iss = allocate_index(cred_type, cred_id, "suspension", "issuer").await?;
let hld = allocate_index(cred_type, cred_id, "suspension", "holder").await?;每次验证都会检查全部三个,只有满足以下条件时凭证才有效:
NOT revoked AND NOT issuer suspended AND NOT holder suspended all strictly
这是我们有点犯迷糊的地方。持有者控制翻转哪个位,他们会发送一条签了名的授权消息,撤销服务在接受这次位翻转之前应该先验证它。但别忘了,签发方的基础设施负责压缩、签名和发布所有的状态列表凭证,包括持有者权威的那些
如果没有这种托管模式,每个持有者都需要自己的 Arweave 访问权限、signing keys 和 CDN 分发。这不现实。BitstringStatusListCredential 的签发方可能与 VerifiableCredential 的签发方不同。授权决定谁可以翻转位。发布决定谁来签名和分发列表。
任何人都应该能调用撤销 API,所以你需要证明调用方确实拥有它要求翻转的那个位。
带域分离的签名授权消息:
Issuer revocation: SHA-256("{namespace}:revoke:v1:{cred_id}:{list_id}:{index}:{nonce}:{timestamp}")
Holder suspension: SHA-256("{namespace}:holder-suspend:v1:{cred_id}:{list_id}:{index}:{nonce}:{timestamp}")前缀 revoke:v1 与 holder-suspend:v1 的区分,能阻止一种叫作跨操作重放的攻击,这样即使两者使用同一个密钥,签发方的撤销签名也无法被用来执行持有者暂停
pub fn verify_issuer(
&self,
issuer_did: &str,
issuer_public_key: &[u8],
credential_id: &str,
status_list_id: &str,
status_list_index: i32,
) -> Result<()> {
let signer_did = self.verification_method.split('#').next().unwrap_or("");
if signer_did != issuer_did {
return Err(Error::AuthorizationFailed("DID mismatch"));
}
if self.nonce.len() < 32 {
return Err(Error::AuthorizationFailed("nonce too short"));
}
let message = format!(
"{}:revoke:v1:{}:{}:{}:{}:{}",
self.namespace, credential_id, status_list_id,
status_list_index, self.nonce, self.created_secs
);
let hash = sha256(message.as_bytes());
match self.signature_type.as_str() {
"Ed25519Signature2020" => ed25519_verify(issuer_public_key, &hash, &self.signature)?,
"EcdsaSecp256k1Signature2019" => secp256k1_verify(issuer_public_key, &hash, &self.signature)?,
_ => return Err(Error::UnsupportedSignatureType),
}
Ok(())
}对于持有者暂停,同样的模式也适用,主要有三点不同
DID 必须始终与主体的 DID 匹配,而不是与签发方匹配DID 服务从 DID 文档中动态解析holder-suspend:v1,而不是 revoke:v1当 next_index 达到列表大小时,你就轮换到一个新列表,仅此而已:
employee-ids (seq 1) => 2,097,152 entries => FULL
employee-ids-2 (seq 2) => 2,097,152 entries => FULL
employee-ids-3 (seq 3) => 412,000 entries => ACTIVElet allocation = match allocate_next_index_tx(&mut tx, list_id, max_index).await {
Ok(counter) => (list_id.clone(), counter),
Err(Error::StatusListFull) => {
let new_list = create_next_list_tx(
&mut tx,
credential_type,
base_list_id,
current_sequence,
size_bits,
status_purpose,
list_authority,
).await?;
let counter = allocate_next_index_tx(&mut tx, &new_list.list_id, max_index).await?;
(new_list.list_id, counter)
}
Err(e) => return Err(e),
};“并发轮换安全”,两个事务可能都看到列表已满。CREATE 使用了 ON CONFLICT DO NOTHING,所以第二个事务会转而 SELECT 已经创建好的那个列表。两者都应该在同一个新列表上继续分配。也不会出现重复的列表。
它看起来会是这样:
| 已签发凭证数 | 每种用途的列表数 | 列表总数 “3 种用途 × 7 种类型” |
|---|---|---|
| 100 万 | 每种类型 1 个 | 21 |
| 1000 万 | 每种类型 ~5 个 | 105 |
| 1 亿 | 每种类型 ~48 个 | 1,008 |
每个列表都可以单独发布和缓存。验证方察觉不到轮换,因为凭证的 statusListCredential URL 指向的是一个具体的列表,而不是一个会轮换的别名。
psql 里的一次位翻转对验证方来说是不可见的。更新后的位串必须经过压缩、签名和发布。这是一条最终一致的流水线

如果 30 秒内有 50 个凭证被撤销,比如发生了一次全面的安全事件,你不会想干等 50 个独立的发布流程,每一个都要压缩 256 KB、签名、上传到永久存储、推送到 CDN。自动发布器会在最后一次更新之后等待指定的 debounce_secs default 60s,然后再发布。如果更新一直不断涌入,max_delay_secs default 120s 无论如何都会强制发布一次
最坏情况下的数据陈旧时间是有上限的,120s 最大延迟 + ~2s 压缩/签名/上传 = 从第一次位翻转到验证方可用,共 ~122 seconds。
附注: “什么时候该考虑改用累加器”,如果你的威胁模型要求对用户而言出示凭证时不可关联,并且需要非撤销证明,比如匿名投票或注重隐私的年龄验证场景。计算和基础设施成本会更高,但这里的隐私保障更强
签发凭证时,三个状态条目对应三次独立的调用。如果第 2 次调用失败,第 1 次调用的分配就成了孤儿。后来我的做法是搭建一个 RPC,在单个数据库事务中完成全部三次分配
如果没有防抖,批量撤销 100 个凭证会触发 100 个独立的发布流程,每一个都要压缩 256 KB、签名、上传到永久存储、推送到 CDN。这完全没必要。加上 60s 的防抖后,这些会合并成一次发布,而 120s 的最大延迟则限定了最坏情况下的陈旧时间。在批量操作期间,这让我们的发布负载降低了大约 50×
永久存储给了你不可变性,但单靠不可变性并不能证明内容就是你所期望的。区块链锚定会在链上记录一个 SHA-256(credential_json)。验证时从存储中取出内容,计算哈希,再与链上的哈希比较。无论哪个系统被篡改,这种不一致都很容易被发现
对于一个有 7 种凭证类型、采用三重分配、使用 2M-bit 列表,并已签发 1000 万个凭证的系统
这篇文章花了我好几周才写完,感谢阅读,欢迎分享 😂❤️
将最新文章直接发送到你的收件箱。