Traduzido do artigo original em inglês. Ler em inglês

Digamos que você emitiu 2 milhões de credenciais verificáveis, podem ser crachás de funcionários, atestações de KYC, provas de idade, atestações de carteira, tudo o que o seu sistema já assinou e distribuiu pela rede para os funcionários. Agora um dispositivo é comprometido e você precisa que todo verificador do planeta rejeite aquela única credencial em questão de minutos, sem revelar qual credencial foi revogada, sem exigir que os verificadores façam requisições ao seu servidor emissor em produção, e sem uma varredura O(n) em uma lista de revogação. Esse é o problema da revogação de credenciais.
As abordagens comuns como CRLs Certificate Revocation Lists e OCSP Online Certificate Status Protocol ou vazam privacidade, ou não escalam, ou precisam de uma infra centralizada que vira um ponto único de falha. O W3C padronizou uma nova solução em 2015 chamada BitstringStatusList. Construir o seu sistema em torno dela permite que ele lide com milhões de credenciais, revogações concorrentes, várias autoridades suspendendo de forma independente, atribuição aleatória de índices para VC por privacidade, e um pipeline preguiçoso que publica quando estiver pronto.
Pense em uma bitstring como um registrador de status gigante, em que cada credencial recebe um índice em uma bitstring enorme. Bit 0 = válida, bit 1 = revogada ou suspensa, depende do seu design. Para revogar a credencial #94,567, por exemplo, você troca o bit 94,567 de 0 para 1, e para checar se ela foi revogada, você só lê esse mesmo bit, e é isso,
Index: 0 1 2 3 4 ... 94567 ... 2097151
Bitstring: 0 0 0 1 0 ... 1 ... 0
↑
credential #94567 is revoked hereUm pipeline bem simples para revogação! A bitstring é comprimida com GZIP, codificada em base64url com um prefixo multibase u, assinada e publicada como uma Verifiable Credential ela mesma, a BitstringStatusListCredential. Um verificador baixa ela, descomprime e lê o bit. Um fetch, uma checagem de bit, pronto.
É por isso que escala: a bitstring tem tamanho fixo, não importa quantas credenciais sejam revogadas. Uma lista de 2 milhões de entradas tem 256 KB no estado descomprimido, mas com taxas de revogação típicas abaixo de 1%, o GZIP reduz isso para 1-3 KB. Uma CDN serve isso em ms.
Taxas de compressão para uma lista 2,097,152-bit de 256 KB:
| Taxa de preenchimento | Bits ligados | Tamanho comprimido | Proporção |
|---|---|---|---|
| 0% (vazia) | 0 | ~300 bytes | 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% (aleatória) | ~1M | ~256 KB | 1:1 |
Com taxas de revogação do mundo real, o status inteiro de 2 milhões de credenciais cabe em menos de 8 KB. Uma CDN consegue cachear e servir isso facilmente.
Quando uma credencial é emitida, você embute uma entrada credentialStatus apontando para a status list. Por exemplo:
{
"@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"
}
}Todo verificador resolve a statusListCredential, descomprime a bitstring, lê o bit 94,567 e, se ele for 1, a credencial está revogada, se for 0 ela continua ativa.
Alguns requisitos do W3C que vale a pena conhecer:
statusListIndex é uma string decimal, não um inteiro131,072 entradas, 16 KB, adotado como padrão para garantir herd privacystatusPurpose na entrada DEVE ser igual ao statusPurpose na status list credentialGZIP conforme a RFC 1952, codificada em multibase com o prefixo u, "base64url, sem padding"Essa é a parte legal. A bitstring é big-endian dentro de cada byte, então o índice 0 é o bit mais à esquerda, o "mais significativo", do byte 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)Em 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);
}Por que 7 - (index % 8)? A spec do W3C define o índice 0 como o bit mais à esquerda, então em um byte 0b1000_0000, essa é a posição de bit 7, contando a partir de 0 pela direita. A subtração converte a ordem da spec para a ordem do byte.
Compressão e codificação para o formato 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))
}NOTA: clear_bit é o que torna a suspensão reversível. Operações de revogação NUNCA devem chamar clear_bit, depois que uma credencial é revogada, ela está morta para sempre, e isso é garantido na camada de serviço, em que o serviço de revogação expõe os endpoints suspend/unsuspend, mas só revoke, sem unrevoke. A bitstring em si não sabe nada de propósito, a irreversibilidade da revogação é uma regra de negócio, não uma propriedade da estrutura de dados
Existe uma possibilidade, que a gente viveu na prática, de vários serviços estarem revogando credenciais ao mesmo tempo. Sem uma coordenação clara, duas trocas de bit concorrentes na mesma lista corrompem (corrupts) a bitstring. O clássico problema de lost update. O servidor A lê a bitstring e troca o bit 100, mas o servidor B lê a mesma bitstring antes de A gravar de volta e troca o bit 200. B grava de volta, sobrescrevendo a mudança de A, e o bit 100, infelizmente, nunca é ligado.
Como eu resolvi isso? Locking pessimista no nível da linha

Em Rust com 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))
}O que está acontecendo aqui:
SELECT ... FOR UPDATE pega um lock no nível da linha, e as transações concorrentes ficam bloqueadas aquiA conta: com 7 tipos de credencial × 3 propósitos, são 21 status lists, então revogações concorrentes em tipos de credencial diferentes nunca disputam entre si. Revogações na mesma lista são serializadas pelo FOR UPDATE. O tempo de retenção do lock fica abaixo de 5ms, "uma leitura, uma escrita, um insert de log", e a 100 revogações/segundo, a profundidade da fila fica abaixo de 1.
Mas o trade-off de I/O é real. Cada revogação lê 256 KB do Psql, troca um bit e grava 256 KB de volta, mais o WAL. Isso dá mais ou menos ~768 KB de I/O por revogação. A 1.000 revogações/minuto, isso é ~750 MB/minute. O Psql aguenta isso tranquilamente em SSDs modernos, mas esse é o principal desafio de escala. Para um throughput maior, o que a gente fez foi agrupar as trocas de bit, acumular as revogações em uma fila e aplicar todas em um único fluxo de leitura, modificação e escrita.
Se você atribui índices sequencialmente, "credencial 1 => índice 0, credencial 2 => índice 1, e assim por diante", um observador atento que conhece os índices de duas credenciais consegue inferir a ordem de emissão delas e o número aproximado de credenciais emitidas entre elas. Os índices DEVEM ser atribuídos de forma aleatória, NUNCA SEQUENCIAL.
De um ponto de vista mais técnico, a solução aqui é uma cifra de permutação Feistel com chave, que mapeia contadores sequenciais para índices pseudoaleatórios. É uma bijeção, 100% determinística, o mesmo contador sempre mapeia para o mesmo índice, e é reversível com a chave
Counter: 0 1 2 3 4 5
↓ Feistel permutation
Index: 847 23 1902 556 12 2041Uma rede Feistel de 6 rodadas com função de rodada 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
}Por que isso funcionou:
≥4 roundsSHA-256(master_secret || list_id || "index-permutation")6× SHA-256 por alocação, ~18μsEntão um hacker ou agente mal-intencionado que obtenha duas credenciais não consegue determinar quantas foram emitidas entre elas, nem qual foi emitida primeiro, segundo ou terceiro. A permutação sempre opera sobre o espaço completo de 2^21 (2.097.152), não importa quantos índices já tenham sido alocados até agora
Uma única lista de revogação não é suficiente. Cada uma com um statusPurpose diferente, e em produção você na verdade precisa de três dimensões de status independentes,

| Cenário | Quem age | Qual lista | É reversível? |
|---|---|---|---|
| Funcionário demitido | O Emissor | revocation | Não, a credencial some para sempre |
| Investigação de fraude pendente | O Emissor | suspension "issuer" | Sim, o emissor tira a suspensão depois de liberado com evidências |
| Um usuário relata que perdeu o celular | O Titular | suspension "holder" | Sim, o titular tira a suspensão pelo novo dispositivo depois de se registrar de novo |
Cada lista que você vê é uma bitstring separada, com uma autoridade de assinatura separada, e este é o schema do db que faz isso funcionar
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)
);Esse cara garante exatamente uma lista ativa por combinação (credential_type, purpose, authority, sequence)
Alocação tripla durante a emissão da credencial, três atribuições de índice independentes para uma única credencial, e as três também ficam no array credentialStatus da credencial
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?;Toda verificação checa as três, e a credencial só é válida se:
NOT revoked AND NOT issuer suspended AND NOT holder suspended all strictly
Foi aqui que a gente se confundiu um pouco. O titular controla qual bit é trocado, ele envia uma mensagem de autorização assinada que o serviço de revogação deve verificar antes de aceitar a troca do bit. Mas lembre que a infra do emissor comprime, assina e publica todas as status list credentials, incluindo as da autoridade do titular
Sem esse modelo custodial, todo titular precisaria do seu próprio acesso ao Arweave, das suas signing keys e de distribuição via CDN. O que não é realista. O emissor da BitstringStatusListCredential pode ser diferente do emissor da VerifiableCredential. A autorização determina quem pode trocar bits. A publicação determina quem assina e distribui a lista.
Qualquer um deveria poder chamar a API de revogação, então você precisa de uma prova de que quem chama é realmente dono do bit que pediu para trocar.
Mensagens de autorização assinadas com separação de domínio:
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}")O prefixo revoke:v1 vs holder-suspend:v1 impede uma coisa chamada replay entre operações, em que a assinatura de revogação de um emissor não pode ser usada para fazer uma suspensão de titular, mesmo que a mesma chave seja usada para as duas
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(())
}Para a suspensão pelo titular, o mesmo padrão se aplica, com três diferenças principais
DID do signatário sempre tem que ser igual ao DID do subject, não ao do emissorDID via o serviço de DIDholder-suspend:v1 em vez de revoke:v1Quando o next_index chega ao tamanho da lista, você faz a rotação para uma lista nova, e é só isso:
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),
};"Segurança na rotação concorrente" Duas txn's podem ver a lista como cheia ao mesmo tempo. O CREATE usa ON CONFLICT DO NOTHING, então a segunda transação cai em um SELECT da lista já criada. As duas devem seguir com a alocação na mesma lista nova. E nada de listas duplicadas.
Como isso vai ficar:
| Credenciais emitidas | Listas por propósito | Total de listas 3 "propósitos × 7 tipos" |
|---|---|---|
| 1 milhão | 1 por tipo | 21 |
| 10 milhões | ~5 por tipo | 105 |
| 100 milhões | ~48 por tipo | 1.008 |
Cada lista pode ser publicada e cacheada por conta própria. Os verificadores não percebem a rotação, porque a URL statusListCredential da credencial aponta para uma lista específica, e não para um alias que rotaciona.
Uma troca de bit no psql é invisível para os verificadores. A bitstring atualizada tem que ser comprimida, assinada e publicada. Esse é um pipeline que, no fim, converge para o mesmo estado

Se 50 credenciais forem revogadas em 30 segundos, digamos, num comprometimento em larga escala, você não vai querer ficar de boa esperando 50 fluxos de publicação separados, cada um comprimindo 256 KB, assinando, subindo para um armazenamento permanente e enviando para a CDN. O auto publisher espera um debounce_secs definido, default 60s, depois da última atualização antes de publicar. Se as atualizações simplesmente continuarem chegando, o max_delay_secs default 120s força uma publicação mesmo assim
No pior cenário, a defasagem é limitada, 120s de atraso máximo + ~2s de compress/sign/upload = ~122 seconds desde a primeira troca de bit até a disponibilidade para o verificador.
OBSERVAÇÃO: "Quando considerar accumulators em vez disso" se o seu modelo de ameaça exige unlinkability de apresentação para os usuários e provas de não revogação, por exemplo para votação anônima ou uma configuração de verificação de idade com privacidade. Os custos computacionais e de infra são maiores, mas as garantias de privacidade aqui são mais fortes
Três chamadas independentes para as três entradas de status durante a emissão da credencial. Se a chamada 2 falhar, a alocação da chamada 1 fica órfã. O que eu fiz então foi configurar uma RPC que faz as três alocações em uma única transação no db
Sem debounce, revogar 100 credenciais em lote dispara 100 fluxos de publicação separados, cada um comprimindo 256 KB, assinando, subindo para o armazenamento permanente e enviando para a CDN. Isso é desnecessário. O debounce de 60s que adicionamos junta tudo isso em uma única publicação, e o atraso máximo de 120s limita a defasagem no pior cenário. Isso cortou a nossa carga de publicação em cerca de 50× durante uma operação em lote
O armazenamento permanente te dá imutabilidade, mas a imutabilidade sozinha não prova que o conteúdo é o que você espera. A âncora na blockchain registra um SHA-256(credential_json) onChain. A verificação busca do armazenamento, calcula o hash e compara com o hash onChain. Se algum dos dois sistemas for adulterado, a diferença é fácil de detectar
Para um sistema com 7 tipos de credencial, alocação tripla e listas de 2M-bit com 10 milhões de credenciais emitidas
Levei algumas semanas para escrever isso, obrigado pela leitura, compartilha aí 😂❤️
Receba os artigos mais recentes no seu e-mail.