# توسيع إبطال بيانات الاعتماد القابلة للتحقق ليشمل ملايين المستخدمين

> لقد أصدرت 2 مليون من بيانات الاعتماد القابلة للتحقق. سُرق هاتف أحد الموظفين. وتحتاج إلى أن ترفض كل جهة تحقق في العالم بيانات الاعتماد تلك بالذات خلال بضع دقائق، دون الكشف عن هويتها، ودون فحص بتعقيد O(n).

بقلم Shodipo Ayomide، نُشرت في 14 مارس 2026. نسخة الويب: https://blog.shodipoayomide.com/ar/scaling-verifiable-credential-revocation-to-millions-of-users

![توسيع إبطال بيانات الاعتماد القابلة للتحقق ليشمل ملايين المستخدمين](https://blog.shodipoayomide.com/banners/scaling-verifiable-credential-revocation-to-millions-of-users.png)

لنفترض أنك أصدرت 2 مليون من بيانات الاعتماد القابلة للتحقق، قد تكون شارات موظفين، أو شهادات KYC، أو إثباتات عمر، أو شهادات محافظ، أي كل ما وقّعه نظامك يومًا ووزّعه عبر الشبكة على الموظفين. والآن تعرّض جهاز للاختراق، وتحتاج إلى أن ترفض كل جهة تحقق على هذا الكوكب بيانات الاعتماد تلك بالذات خلال دقائق، دون الكشف عن بيانات الاعتماد التي أُبطلت، ودون أن تضطر جهات التحقق إلى إرسال طلبات إلى خادم الإصدار الذي نشرته، ودون فحص بتعقيد `O(n)` عبر قائمة إبطال. هذه هي مشكلة إبطال بيانات الاعتماد.

الأساليب المعتادة مثل `CRLs` **Certificate Revocation Lists** و `OCSP` **Online Certificate Status Protocol** إما أنها تسرّب الخصوصية، أو لا تتوسع، أو تحتاج إلى بنية تحتية مركزية تصبح نقطة فشل وحيدة. وقد وحّدت `W3C` حلًا جديدًا في 2015 يُسمى [BitstringStatusList](https://www.w3.org/TR/vc-bitstring-status-list/). وبناء نظامك حول هذا الحل يسمح له بالتعامل مع ملايين بيانات الاعتماد، وعمليات الإبطال المتزامنة، وتعليق جهات متعددة لبيانات الاعتماد بشكل مستقل، وتعيين فهارس عشوائية لبيانات الاعتماد (VC) حفاظًا على الخصوصية، وخط نشر كسول لا ينشر إلا عند الجاهزية.

## كيف تحل قوائم Bitstring هذه المشكلة

فكّر في سلسلة البتات (bitstring) على أنها سجل حالة ضخم، تحصل فيه كل بيانات اعتماد على **فهرس** في سلسلة بتات ضخمة. البت `0` = صالحة، والبت `1` = مُبطلة أو معلّقة بحسب تصميمك. ولإبطال بيانات الاعتماد `#94,567` على سبيل المثال، تقلب البت `94,567` من `0` إلى `1`، وللتحقق مما إذا كانت مُبطلة، تقرأ ذلك البت نفسه فقط، وهذا كل شيء،

```bash
Index:     0  1  2  3  4 ... 94567 ... 2097151
Bitstring: 0  0  0  1  0 ...   1   ...    0

                                ↑
                      credential #94567 is revoked here
```

خط إبطال بسيط جدًا! تُضغط سلسلة البتات بـ `GZIP`، وتُرمَّز بـ `base64url` مع بادئة multibase هي `u`، ثم تُوقَّع، وتُنشر بوصفها `Verifiable Credential` بحد ذاتها، وهي `BitstringStatusListCredential`. تجلبها جهة التحقق، وتفك ضغطها، وتقرأ البت. جلب واحد، وفحص بت واحد، وانتهى الأمر.

ولهذا تتوسع: فسلسلة البتات ثابتة الحجم مهما بلغ عدد بيانات الاعتماد المُبطلة. القائمة ذات الـ 2 مليون مدخل حجمها `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 |

عند معدلات الإبطال في العالم الحقيقي، تتسع الحالة الكاملة لـ 2 مليون من بيانات الاعتماد في أقل من `8 KB`. ويمكن لشبكة CDN تخزين ذلك مؤقتًا وتقديمه بسهولة.

## ربط بيانات الاعتماد بسلسلة البتات

عند إصدار بيانات اعتماد، تُضمّن فيها مدخل `credentialStatus` يشير إلى قائمة الحالة. على سبيل المثال:

```json
{
  "@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` في المدخل قيمة `statusPurpose` في بيانات اعتماد قائمة الحالة
- ضغط `GZIP` وفق `RFC 1952`، مع ترميز multibase بالبادئة `u`، «base64url، دون حشو»

## التعامل مع البتات على مستوى البايت

هذا هو الجزء الممتع. سلسلة البتات `big-endian` داخل كل بايت، لذا فالفهرس `0` هو البت الأيسر «الأكثر أهمية» في البايت `0`.

```bash
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:
```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`، يكون هذا هو موضع البت `7` بالعد من `0` من اليمين. ويحوّل الطرح ترتيب المواصفة إلى ترتيب البايت.

الضغط والترميز إلى صيغة `W3C`:

```rust
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 أبدًا للأسف.

كيف أصلحت هذا؟ بالقفل المتشائم على مستوى الصف

![القفل المتشائم على مستوى الصف](https://i.ibb.co/234SsZ9L/diagram.png)

في Rust باستخدام `sqlx`:
```rust
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` على قفل على مستوى الصف، فتُحجب المعاملات المتزامنة هنا
- يتحقق مما إذا كان البت مضبوطًا بالفعل «idempotent»
- يقلب البت في الذاكرة، ويكتب سلسلة البتات المحدّثة
- يبقى القفل محتجزًا طوال المعاملة كاملة، القراءة والقلب والكتابة والتسجيل

الحسابات: مع 7 أنواع من بيانات الاعتماد × 3 أغراض، يصبح لدينا 21 قائمة حالة، لذا فإن عمليات الإبطال المتزامنة على أنواع مختلفة من بيانات الاعتماد لا تتنافس أبدًا فيما بينها. أما عمليات الإبطال على القائمة نفسها فتُنفَّذ تسلسليًا عبر `FOR UPDATE`. ومدة احتجاز القفل أقل من 5ms «قراءة واحدة، وكتابة واحدة، وإدراج سجل واحد»، وعند 100 عملية إبطال في الثانية، يبقى عمق الطابور أقل من 1.

لكن المقايضة في الإدخال والإخراج حقيقية. فكل عملية إبطال تقرأ `256 KB` من Psql، وتقلب بتًا واحدًا، وتكتب `256 KB` مجددًا، إضافة إلى `WAL`. أي ما يقارب `~768 KB` من I/O لكل عملية إبطال. وعند 1,000 عملية إبطال في الدقيقة، يصبح ذلك `~750 MB/minute`. يتعامل Psql مع هذا جيدًا على أقراص SSD الحديثة، لكنه التحدي الرئيسي في التوسع. ولإنتاجية أعلى، ما فعلناه هو تجميع عمليات قلب البتات، أي مراكمة عمليات الإبطال في طابور وتطبيقها في تدفق واحد من القراءة والتعديل والكتابة.

## لماذا يسرّب التعيين التسلسلي للفهارس المعلومات

إذا عيّنت الفهارس تسلسليًا «بيانات الاعتماد 1 => الفهرس 0، وبيانات الاعتماد 2 => الفهرس 1، وهكذا»، فإن المراقب المنتبه الذي يعرف فهرسي بيانات اعتماد يستطيع استنتاج ترتيب إصدارهما والعدد التقريبي لبيانات الاعتماد الصادرة بينها جميعًا. ينبغي أن تُعيَّن الفهارس عشوائيًا، وليس تسلسليًا أبدًا.

ومن منظور تقني أكثر، فإن الحل هنا هو [شيفرة تبديل Feistel](https://lib.rs/crates/feistel-permutation-rs) ذات مفتاح، تحوّل العدادات التسلسلية إلى فهارس شبه عشوائية. إنها دالة تقابل، حتمية 100%، فالعداد نفسه يتحول دائمًا إلى الفهرس نفسه، وقابلة للعكس باستخدام المفتاح

```bash
Counter:   0   1   2   3   4   5
              ↓ Feistel permutation
Index:   847  23  1902  556  12  2041
```

شبكة `Feistel` من 6 جولات مع دالة جولة `SHA-256`:

```rust
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
}
```

لماذا نجح هذا:

- كل عداد يتحول إلى فهرس فريد «تقابلي»، وهي خاصية مثبتة لشبكات Feistel المتوازنة ذات `≥4 rounds`
- المفاتيح المختلفة تنتج تبديلات مختلفة. وكل قائمة تشتق مفتاحها الخاص، `SHA-256(master_secret || list_id || "index-permutation")`
- عبء إضافي لا يُذكر، `6×` من `SHA-256` لكل عملية تخصيص، `~18μs`

لذا فإن المخترق أو أي طرف يحصل على بيانات اعتماد اثنتين لا يستطيع تحديد عدد ما صدر بينهما، ولا أيهما صدرت أولًا أو ثانيًا أو ثالثًا. ويعمل التبديل دائمًا على الفضاء الكامل `2^21` (2,097,152) بغض النظر عن عدد الفهارس التي خُصصت حتى الآن

## ثلاث قوائم لكل بيانات اعتماد

قائمة إبطال واحدة لا تكفي. فلكل قائمة `statusPurpose` مختلف، وفي بيئة الإنتاج تحتاج فعليًا إلى ثلاثة أبعاد حالة مستقلة،

![3 قوائم لكل بيانات اعتماد](https://i.ibb.co/xqTJ16pv/diagram2.png)

| السيناريو | من يتصرف | أي قائمة | قابلة للعكس؟ |
|----------|----------|------------|-------------|
| إنهاء خدمة موظف | الجهة المُصدِرة | revocation | لا، بيانات الاعتماد انتهت نهائيًا |
| تحقيق في احتيال قيد الانتظار | الجهة المُصدِرة | suspension "issuer" | نعم، تلغي الجهة المُصدِرة التعليق بعد التبرئة بالأدلة |
| مستخدم يبلّغ عن فقدان هاتفه | الحامل | suspension "holder" | نعم، يلغي الحامل التعليق من جهازه الجديد بعد إعادة التسجيل |

كل قائمة تراها هي `bitstring` منفصلة لها جهة توقيع منفصلة، وهذا هو مخطط قاعدة البيانات الذي يجعل ذلك يعمل

```sql
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` في بيانات الاعتماد

```rust
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`. التخويل يحدد من يستطيع قلب البتات. والنشر يحدد من يوقّع القائمة ويوزعها.

## إثبات أن لديك الحق في الإبطال

يجب أن يكون أي أحد قادرًا على استدعاء واجهة الإبطال البرمجية، لذا تحتاج إلى إثبات أن المستدعي يملك فعلًا البت الذي طلب قلبه.

رسائل تخويل موقّعة مع فصل النطاق:

```bash
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` تمنع ما يُسمى بإعادة التشغيل عبر العمليات (cross operation replay)، فلا يمكن استخدام توقيع الإبطال الخاص بالجهة المُصدِرة لتنفيذ تعليق من جانب الحامل حتى لو استُخدم المفتاح نفسه للعمليتين

```rust
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`

## ماذا يحدث عندما لا تكفي 2 مليون

عندما يصل `next_index` إلى حجم القائمة، تنتقل إلى قائمة جديدة، وهذا كل شيء:

```bash
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
```

```rust
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 أنواع» |
|-------------------|-------------------|-------------------------------------|
| 1 مليون | 1 لكل نوع | 21 |
| 10 ملايين | ~5 لكل نوع | 105 |
| 100 مليون | ~48 لكل نوع | 1,008 |

يمكن نشر كل قائمة وتخزينها مؤقتًا بمفردها. ولا تلاحظ جهات التحقق التدوير لأن رابط `statusListCredential` في بيانات الاعتماد يشير إلى قائمة محددة، وليس إلى اسم مستعار متغير.

## خط النشر

قلب البت في psql غير مرئي لجهات التحقق. يجب ضغط سلسلة البتات المحدّثة وتوقيعها ونشرها. وهذا خط نشر متّسق في نهاية المطاف

![تدفق النشر والترسيخ](https://i.ibb.co/HTcMQSR2/daigarm3-2.png)

### منطق debounce المثالي

إذا أُبطلت 50 بيانات اعتماد خلال 30 ثانية، ولنقل في حالة اختراق شامل، فأنت لا تريد أن تجلس متفرجًا على 50 تدفق نشر منفصلًا، يضغط كل منها `256 KB`، ويوقّع، ويرفع إلى تخزين دائم، ويدفع إلى CDN. ينتظر الناشر التلقائي مدة محددة `debounce_secs` `default 60s` بعد آخر تحديث قبل النشر. وإذا ظلت التحديثات تتوالى ببساطة، فإن `max_delay_secs` `default 120s ` يفرض النشر رغم ذلك

التقادم في أسوأ الحالات محدود، `120s` أقصى تأخير + `~2s` للضغط/التوقيع/الرفع = `~122 seconds` من أول قلب بت حتى إتاحته لجهات التحقق.

**ملاحظة جانبية:**
«متى تفكر في استخدام accumulators بدلًا من ذلك» إذا كان نموذج التهديد لديك يتطلب عدم قابلية ربط العروض بالمستخدمين وإثباتات عدم الإبطال، لنقل في التصويت المجهول أو في إعداد للتحقق من العمر يحفظ الخصوصية. التكاليف الحسابية وتكاليف البنية التحتية أعلى، لكن ضمانات الخصوصية أقوى هنا

## أمور رئيسية أخرى تعلمتها على طول الطريق

### التخصيص الثلاثي غير الذرّي

ثلاثة استدعاءات مستقلة لمدخلات الحالة الثلاثة أثناء إصدار بيانات الاعتماد. فإذا فشل الاستدعاء 2، يصبح تخصيص الاستدعاء 1 يتيمًا. وما فعلته بعد ذلك هو إعداد RPC ينفّذ عمليات التخصيص الثلاث كلها في معاملة قاعدة بيانات واحدة

### الـ debounce يمنع عواصف إعادة النشر

من دون debounce، فإن إبطال `100` بيانات اعتماد دفعة واحدة يطلق `100` تدفق نشر منفصلًا، يضغط كل منها `256 KB`، ويوقّع، ويرفع إلى التخزين الدائم، ويدفع إلى CDN. وهذا غير ضروري. فالـ debounce المضاف بمدة `60s` يدمج هذه كلها في عملية نشر واحدة، والتأخير الأقصى `120s` يحدّ من التقادم في أسوأ الحالات. وقد خفّض هذا حمل النشر لدينا بنحو `50×` أثناء العمليات المجمّعة

### تخزين مزدوج، وسلامة مزدوجة

يمنحك التخزين الدائم عدم القابلية للتغيير، لكن عدم القابلية للتغيير وحدها لا تثبت أن المحتوى هو ما تتوقعه. يسجّل الترسيخ على سلسلة الكتل `SHA-256(credential_json)` على السلسلة. ويجلب التحقق المحتوى من التخزين، ويحسب تجزئته، ويقارنها بالتجزئة المسجّلة على السلسلة. فإذا تم العبث بأي من النظامين، يمكن اكتشاف عدم التطابق بسهولة

### الأرقام على نطاق واسع

لنظام فيه 7 أنواع من بيانات الاعتماد، وتخصيص ثلاثي، وقوائم `2M-bit`، عند إصدار 10 ملايين من بيانات الاعتماد

- قوائم الحالة، ~105 قائمة فعّالة «5 لكل نوع × 7 أنواع × 3 أغراض»
- تخزين سلاسل البتات في قاعدة البيانات، ~105 × 256 KB = ~27 MB
- عمليات تخصيص الفهارس، 30 مليون صف «3 لكل بيانات اعتماد»
- بيانات الاعتماد المنشورة، ~105 × 3 KB = ~315 KB إجمالًا على CDN
- لكل عملية إبطال، ~768 KB من I/O لقاعدة البيانات، واحتجاز للقفل أقل من 5ms، وزمن نشر 60-120s على الأكثر
- لكل عملية تحقق، ~9 KB تنزيلًا من CDN، وأقل من 10ms إجمالًا «مع ذاكرة تخزين مؤقت دافئة»
- سقف الإنتاجية «PostgreSQL واحد»، ~200 عملية إبطال/ثانية لكل قائمة، و ~1000 عملية تخصيص/ثانية لكل قائمة، و ~10,000 فحص حالة/ثانية

## روابط أساسية

- [W3C BitstringStatusList v1.0](https://www.w3.org/TR/vc-bitstring-status-list/)، توصية W3C الرسمية لإبطال بيانات الاعتماد وتعليقها
- [W3C Verifiable Credentials Data Model v2.0](https://www.w3.org/TR/vc-data-model-2.0/)، المواصفة الأم لبيانات الاعتماد القابلة للتحقق
- [Digital Bazaar vc-bitstring-status-list](https://github.com/digitalbazaar/vc-bitstring-status-list)، التطبيق المرجعي بلغة JS
- [W3C Data Integrity EdDSA Cryptosuites](https://www.w3.org/TR/vc-di-eddsa/)، مواصفة التوقيع eddsa-jcs-2022
- [ALLOSAUR: Accumulator with Low-Latency Oblivious and Succinct Revocation](https://eprint.iacr.org/2022/1362)، أقوى نهج بديل للإبطال
- [CRSet: Cascade-Based Revocation Sets](https://arxiv.org/abs/2501.17089)، نهج يعتمد على سلسلة متتالية من مرشحات Bloom للإبطال مع الحفاظ على الخصوصية
- [مواصفة Hyperledger AnonCreds](https://hyperledger.github.io/anoncreds-spec/)، بديل قائم على accumulators مع عدم قابلية ربط كاملة

> استغرقت كتابة هذا مني بضعة أسابيع، شكرًا على القراءة، وأرجو أن تشاركه 😂❤️
