全部文章

将可验证凭证撤销扩展到数百万用户

·
2026年3月14日
·
阅读约 11 分钟
·
••• 次浏览
·
••• 次点赞

本文译自英文原文。 阅读英文原文

将可验证凭证撤销扩展到数百万用户

假设你已经签发了 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 KB170:1
1%20,971~8 KB32:1
10%209,715~90 KB2.8:1
50%(随机)~1M~256 KB1: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 要求:

字节级的位操作

这是最酷的部分。位串在每个字节内是 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))
}

这里发生了什么:

算一下: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
}

为什么这行得通:

所以,拿到两个凭证的黑客或攻击者,无法确定它们之间签发了多少个凭证,也无法确定哪个是第一个、第二个或第三个签发的。无论目前已经分配了多少个索引,置换始终在完整的 2^21(2,097,152)空间上进行

每个凭证三个列表

单个撤销列表是不够的。每个列表有不同的 statusPurpose,在生产环境中你实际上需要三个独立的状态维度,

每个凭证 3 个列表
场景谁来操作哪个列表是否可逆?
员工被解雇签发方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(())
}

对于持有者暂停,同样的模式也适用,主要有三点不同

200 万不够用时怎么办

当 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   => ACTIVE
let 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 万个凭证的系统

必读链接

这篇文章花了我好几周才写完,感谢阅读,欢迎分享 😂❤️

•••
分享本文:

获取最新动态

将最新文章直接发送到你的收件箱。