كل المقالات

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

·
14 مارس 2026
·
وقت القراءة: 11 د
·
••• مشاهدة
·
••• إعجاب

مترجمة عن المقالة الأصلية باللغة الإنجليزية. اقرأها بالإنجليزية

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

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

الأساليب المعتادة مثل CRLs Certificate Revocation Lists و OCSP Online Certificate Status Protocol إما أنها تسرّب الخصوصية، أو لا تتوسع، أو تحتاج إلى بنية تحتية مركزية تصبح نقطة فشل وحيدة. وقد وحّدت W3C حلًا جديدًا في 2015 يُسمى BitstringStatusList. وبناء نظامك حول هذا الحل يسمح له بالتعامل مع ملايين بيانات الاعتماد، وعمليات الإبطال المتزامنة، وتعليق جهات متعددة لبيانات الاعتماد بشكل مستقل، وتعيين فهارس عشوائية لبيانات الاعتماد (VC) حفاظًا على الخصوصية، وخط نشر كسول لا ينشر إلا عند الجاهزية.

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

فكّر في سلسلة البتات (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، وتُرمَّز بـ 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 KB170:1
1%20,971~8 KB32:1
10%209,715~90 KB2.8:1
50% (عشوائية)~1M~256 KB1:1

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

الضغط والترميز إلى صيغة 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.

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

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

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

ومن منظور تقني أكثر، فإن الحل هنا هو شيفرة تبديل Feistel ذات مفتاح، تحوّل العدادات التسلسلية إلى فهارس شبه عشوائية. إنها دالة تقابل، حتمية 100%، فالعداد نفسه يتحول دائمًا إلى الفهرس نفسه، وقابلة للعكس باستخدام المفتاح

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

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

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

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

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

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

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(())
}

بالنسبة لتعليق الحامل، ينطبق النمط نفسه مع ثلاثة فروق رئيسية

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

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

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

خط النشر

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

تدفق النشر والترسيخ

منطق 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 ملايين من بيانات الاعتماد

روابط أساسية

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

•••
شارك هذه المقالة:

ابقَ على اطلاع

احصل على أحدث المقالات في بريدك الوارد.