التعليم والبحث العلمي الأدلة التقنية

الشهادات القابلة للتحقق Verifiable Credentials وOpen Badges

كيف تصدر الجامعة شهادة رقمية موقعة بمعيار Open Badges 3.0 تتحقق منها أي جهة عمل بنفسها، وما الذي يقدمه Moodle اليوم.

الشهادات القابلة للتحقق Verifiable Credentials وOpen Badges

عندما يتقدم خريج إلى وظيفة، ترسل جهة العمل عادة بريداً إلى عمادة القبول والتسجيل تسأل فيه: هل هذه الشهادة صحيحة؟ ثم تنتظر أياماً، والعمادة نفسها تبحث في النظام وترد يدوياً، وهذا العمل يتكرر آلاف المرات في السنة، ومع ذلك فالملف PDF الذي يحمله الخريج يمكن لأي شخص أن يعدله في دقائق. في مقال الأنظمة التي تحتاجها الجامعة وصلنا إلى مرحلة التخرج والخريجين، وفي هذا المقال سوف نأخذ جزءاً واحداً منها: الشهادة الرقمية التي يتحقق منها أي شخص بنفسه، دون أن يتصل بالجامعة، وسوف نتناول:

  • لماذا لا تكفي الشهادة PDF ولا رمز QR يفتح صفحة في موقع الجامعة.
  • نموذج الشهادات القابلة للتحقق Verifiable Credentials من W3C، ومعيار الشارات المفتوحة Open Badges 3.0 المبني عليه.
  • ما الذي يقدمه Moodle اليوم في الشارات Badges، مع شارة حقيقية أصدرناها من مقرر وقرأنا ملفها.
  • تجربة كاملة: جامعة تصدر شهادة موقعة، ومتحقق يفحصها خطوة خطوة، ثم ثلاث محاولات تزوير، ودمج الشهادة داخل صورة الشارة.

لماذا لا يكفي ملف PDF؟

الحل البسيط هو أن تصدر الجامعة الشهادة ملفاً PDF عليه شعارها وتوقيع العميد، وهذا ما تفعله أغلب الجامعات، ومشكلته أن الملف صورة لشهادة وليس دليلاً عليها، فلا يوجد فيه شيء يمنع تعديل الاسم أو التقدير. والحل التالي هو رمز QR في الملف يفتح صفحة تحقق في موقع الجامعة، وهو أفضل، ولكن له ثلاث مشكلات: يعمل ما دامت الصفحة موجودة، فإذا تغير النظام أو النطاق تعطلت كل الروابط القديمة، وكل عملية تحقق تمر بسيرفر الجامعة فتعرف الجامعة من يتحقق من شهادات خريجيها ومتى، والأخطر أن المزور يستطيع أن يضع رمز QR يفتح صفحة في نطاق يشبه نطاق الجامعة.

والحل الذي وصلت إليه المعايير الحديثة هو التوقيع الرقمي Digital Signature: الجامعة توقع بيانات الشهادة بمفتاحها الخاص Private Key، وتنشر مفتاحها العام Public Key مرة واحدة على نطاقها، وأي متحقق يجلب المفتاح العام ويتأكد بنفسه أن البيانات لم تتغير حرفاً واحداً منذ وقعتها الجامعة، وهي الفكرة نفسها التي تعمل بها شهادات TLS في المتصفح.

📌
في 28 أغسطس 2017 أعلن مكتب المدعي العام الأمريكي في المنطقة الجنوبية لنيويورك الحكم بسجن أحد مسؤولي شركة Axact 21 شهراً، مع مصادرة 5,303,020 دولاراً، في قضية مصنع شهادات Diploma Mill أدار مواقع لمدارس وجامعات غير موجودة، يدفع فيها الناس رسوم الدراسة ثم يستلمون شهادات لا قيمة لها. والدرس هنا أن الشهادة الورقية أو الملف لا يثبت شيئاً بنفسه، والذي يثبت هو أن تتحقق من الجهة المصدرة ومن توقيعها.

الشهادة القابلة للتحقق Verifiable Credential

نموذج الشهادات القابلة للتحقق هو معيار من W3C، وصل إصداره الثاني Verifiable Credentials Data Model 2.0 إلى مرحلة التوصية W3C Recommendation في 15 مايو 2025، وهو لا يخص التعليم وحده، فيمكن أن تكون الشهادة رخصة قيادة أو بطاقة موظف، ولكن الجامعات من أكثر من يستفيد منه. والنموذج فيه ثلاثة أطراف كما في الصورة التالية:

مخطط الأطراف الثلاثة في الشهادة القابلة للتحقق: الجهة المصدرة Issuer وهي الجامعة توقع بالمفتاح الخاص، وتصدر الشهادة موقعة OpenBadgeCredential إلى حامل الشهادة Holder وهو الطالب يحفظها في محفظة Wallet أو صورة PNG، ثم يقدمها لمن يريد برابط أو ملف أو صورة، والمتحقق Verifier وهو جهة العمل أو جامعة أخرى يجلب المفتاح العام من نطاق الجهة ويتحقق من التوقيع بنفسه دون أن يتصل بعمادة القبول والتسجيل
الجامعة توقع مرة واحدة، والطالب يحمل الشهادة، والمتحقق يثق في المفتاح المنشور على نطاق الجامعة
  • الجهة المصدرة Issuer: الجامعة أو المدرسة أو مركز التدريب، وهي التي تملك المفتاح الخاص.
  • حامل الشهادة Holder: الطالب، يحفظ الشهادة في تطبيق محفظة Wallet أو ملف، ويقرر لمن يعرضها.
  • المتحقق Verifier: جهة العمل أو جامعة أخرى، يفحص التوقيع والبيانات دون أن يحتاج إلى حساب في نظام الجامعة.

وقد يتساءل البعض: هل يعني هذا استخدام Blockchain؟ والإجابة لا، فالمعيار لا يشترطه، والتوقيع الرقمي العادي ومفتاح منشور على نطاق الجامعة يكفيان، وهذا ما سوف نفعله في التجربة.

الشارات المفتوحة Open Badges 3.0

معيار الشارات المفتوحة Open Badges تديره منظمة 1EdTech، وهي نفسها التي تدير LTI الذي شرحناه في مقال أنظمة إدارة التعلم LMS. والإصداران 2.0 و2.1 من المعيار يعتمدان على التحقق المستضاف Hosted Verification، أي أن ملف الشارة يحمل رابطاً في موقع الجهة المصدرة، والمتحقق يفتح الرابط ليتأكد، وهذه هي مشكلة رمز QR نفسها. وأما الإصدار 3.0 فقد أعاد بناء الشارة لتصبح شهادة قابلة للتحقق بنموذج W3C، ووثيقة المواصفة، وآخر نسخة منها 1.4.5 بتاريخ 29 يونيو 2026، تحدد ما يلي:

  • النوع: الشهادة من نوع OpenBadgeCredential، وفيها الجهة المصدرة issuer، والطالب credentialSubject، والإنجاز نفسه achievement بمعاييره.
  • التوقيع Proof: بطريقتين، إما JWT موقع VC-JWT، والخوارزمية فيه RS256 كحد أدنى، وإما توقيع مدمج في ملف JSON Linked Data Proof، واختبارات المطابقة من 1EdTech تقبل فيه حالياً مجموعة EdDSA فقط.
  • الدمج في الصورة Baking: يمكن أن توضع الشهادة داخل صورة الشارة PNG في قطعة نصية من نوع iTXt اسمها openbadgecredential، فتنتقل الشهادة مع الصورة أينما ذهبت.
  • الإلغاء Revocation: عن طريق Bitstring Status List، أي قائمة تنشرها الجهة المصدرة فيها بت لكل شهادة يبين هل ألغيت أم لا.
  • هوية الطالب: يمكن ألا يظهر البريد الإلكتروني صريحاً، وإنما هاش Hash له مع ملح Salt، فيثبت الطالب أنه صاحب البريد دون أن يقرأه أي شخص يرى الشهادة.

الشارات Badges في Moodle اليوم

إذا كانت جامعتك تستخدم Moodle كما في دليل تثبيت Moodle، فالشارات موجودة فيه منذ سنوات، ويمنحها المعلم يدوياً أو تمنح تلقائياً عند إكمال مقرر أو نشاط، ولكن لاحظ أي إصدار من المعيار تتبعه: سجل المشكلات في Moodle فيه مهمة اعتماد Open Badges 2.0 و2.1 لإصدار Moodle 5.3، ومهمة تطبيق Open Badges 3.0 وحالتها حتى كتابة هذا المقال «قيد التطوير» Development in progress. أي أن Moodle اليوم يصدر شارات بالإصدار 2، ولا يصدر شهادات موقعة بالإصدار 3.

ولنرى ذلك بأنفسنا، أصدرنا شارة لمقرر «مقدمة في الشبكات» في مختبر Moodle 5.2.4، بمعيار المنح اليدوي من المعلم Manual Award، ومنحناها للطالب خالد، والصورة التالية لصفحة الشارة كما يراها أي شخص معه الرابط:

صفحة شارة في Moodle بالعربية: صورة الشارة الخضراء وعليها NET101 مقدمة في الشبكات Example University وعلامة صح، وبجانبها عنوان الشارة مقدمة في الشبكات NET101، والممنوحة إلى خالد الطالب، والمصدرة في 21 أكتوبر 2026، والمصدرة من قبل Example University، والمقرر مقدمة في الشبكات، والوصف أتم الطالب مقرر مقدمة في الشبكات بنجاح، والمعايير يجب منح هذه الشارة من قبل مستخدم له الدور التالي المعلم
شارة المقرر في Moodle: الصفحة بالعربية، والمعيار منح يدوي من المعلم

ولكن الصفحة ليست الشارة، فالشارة نفسها ملف JSON يسمى الإثبات Assertion، نجلبه من الرابط الذي يعطيه Moodle لكل شارة ممنوحة:

curl -sL "http://127.0.0.1:31080/badges/json/assertion.php?b=0af21f3007c0da07930685e170091ba4010dd46d&obversion=v2p0"
{
    "@context": "https://w3id.org/openbadges/v2",
    "type": "Assertion",
    "id": "http://127.0.0.1:31080/badges/json/assertion.php?b=0af21f3007c0da07930685e170091ba4010dd46d&obversion=v2p0",
    "recipient": {
        "type": "email",
        "hashed": true,
        "salt": "badges1792587988",
        "identity": "sha256$458635ef01dbbbc710876c7048e517526e9c5080c0e80cfe5c84e8f3735adde1"
    },
    "verify": {
        "type": "hosted",
        "url": "http://127.0.0.1:31080/badges/json/assertion.php?b=0af21f3007c0da07930685e170091ba4010dd46d&obversion=v2p0"
    },
    "issuedOn": "2026-10-21T21:37:16+01:00",
    "badge": {
        "@context": "https://w3id.org/openbadges/v2",
        "id": "http://127.0.0.1:31080/badges/json/badge.php?id=1&obversion=v2p0",
        "type": "BadgeClass",
        "name": "مقدمة في الشبكات NET101",
        "description": "أتم الطالب مقرر مقدمة في الشبكات بنجاح",
        "version": "2",
        "@language": "ar",
        "image": {
            "id": "http://127.0.0.1:31080/pluginfile.php/12/badges/badgeimage/1/f3",
            "caption": "Image caption"
        },
        "criteria": {
            "id": "http://127.0.0.1:31080/badges/badgeclass.php?id=1",
            "narrative": "يمنح المستخدمون هذه الشارة عند إكمالهم للمتطلبات التالية:\n * يجب منح هذه الشارة من قبل مستخدم له الدور التالي:\nالمعلم\n\n"
        },
        "issuer": {
            "name": "Example University",
            "url": "https://example.edu",
            "email": "[email protected]",
            "@context": "https://w3id.org/openbadges/v2",
            "id": "http://127.0.0.1:31080/badges/json/issuer.php?obversion=v2p0&badgeid=1",
            "type": "Issuer"
        }
    },
    "evidence": "http://127.0.0.1:31080/badges/badge.php?hash=0af21f3007c0da07930685e170091ba4010dd46d"
}

في المخرج أعلاه لاحظ التالي:

  • لا يوجد توقيع: الحقل verify من نوع hosted، أي أن الدليل الوحيد على صحة الشارة هو أن الرابط نفسه في موقع الجامعة ما زال يعيد هذا الملف، فإذا توقف السيرفر أو تغير عنوان الموقع wwwroot لم يعد هناك ما يثبت الشارة. ولاحظ أن عنوان المختبر 127.0.0.1 دخل في كل الروابط، لذلك لا تمنح شارات قبل أن يستقر عنوان الموقع الحقيقي.
  • البريد مخفي بالهاش: الحقل identity هو SHA-256 للبريد مع الملح، وتأكدنا من ذلك بحساب الهاش بأنفسنا للبريد [email protected] مع الملح badges1792587988، فكانت النتيجة sha256$458635ef01dbbbc710876c7048e517526e9c5080c0e80cfe5c84e8f3735adde1، وهي القيمة نفسها.
  • بقايا افتراضية: وصف الصورة caption هو «Image caption» لأننا لم نكتب وصفاً عند إنشاء الشارة، وهو يظهر في الملف الذي يراه الآخرون، فاكتبه.

والخلاصة أن شارات Moodle مفيدة للتحفيز داخل المقرر ولإرسالها إلى محفظة شارات Backpack، ولكنها لا تصلح بعد شهادة رسمية يعتمد عليها طرف خارجي، لذلك سوف نبني الآن الجزء الذي ينقصها.

التجربة العملية: إصدار شهادة موقعة والتحقق منها

سوف نبني ثلاثة أجزاء صغيرة بلغة JavaScript على Node.js، بمكتبة jose للتوقيع ومكتبة Ajv لفحص البنية:

  1. الجهة المصدرة: سيرفر صغير يولد مفتاح RSA للجامعة مرة واحدة، وينشر ملفها التعريفي Profile ومفتاحها العام، ويصدر شهادة OpenBadgeCredential موقعة بالصيغة VC-JWT.
  2. المتحقق: سكربت يفحص الشهادة بالترتيب الذي تحدده المواصفة.
  3. المزور: سكربت يحاول ثلاث طرق للتزوير، لنرى أين يقف كل منها.

أنشئ مجلداً للمختبر وفيه الملف package.json، وثبت الإصدارات حتى يبقى المختبر كما هو:

{ "name": "edu5-vc-lab", "private": true, "type": "module",
  "dependencies": { "jose": "6.2.12", "ajv": "8.20.0", "ajv-formats": "3.0.1" } }
docker run --rm -v "$PWD":/app -w /app node:24-alpine npm install --no-audit --no-fund

الجهة المصدرة issuer.mjs

الملف التالي هو الجامعة في المختبر، وعنوانها http://edu5-vc-issuer:8080، وفي الواقع يكون نطاق الجامعة مثل https://credentials.example.edu:

// The university's issuer: creates its signing key once, publishes its profile and public key over HTTP,
// and signs an OpenBadgeCredential (Open Badges 3.0) as a Compact JWS (VC-JWT, RS256).
import { generateKeyPair, exportJWK, importJWK, SignJWT } from 'jose';
import { createServer } from 'node:http';
import { createHash, randomUUID } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';

const BASE = process.env.ISSUER_URL || 'http://edu5-vc-issuer:8080';
const ISSUER_ID = `${BASE}/issuers/1`;
const KID = `${BASE}/issuers/1/keys/1`;
mkdirSync('/data', { recursive: true });

async function key() {
  if (!existsSync('/data/issuer-private.jwk.json')) {
    const { privateKey, publicKey } = await generateKeyPair('RS256', { modulusLength: 2048, extractable: true });
    writeFileSync('/data/issuer-private.jwk.json', JSON.stringify(await exportJWK(privateKey)));
    writeFileSync('/data/issuer-public.jwk.json', JSON.stringify({ ...(await exportJWK(publicKey)), kid: KID, alg: 'RS256', use: 'sig' }));
  }
  return { priv: JSON.parse(readFileSync('/data/issuer-private.jwk.json')), pub: JSON.parse(readFileSync('/data/issuer-public.jwk.json')) };
}

async function issue(email) {
  const { priv } = await key();
  const salt = randomUUID().slice(0, 8);
  const now = new Date(); now.setMilliseconds(0);
  const credential = {
    '@context': ['https://www.w3.org/ns/credentials/v2', 'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json'],
    id: `urn:uuid:${randomUUID()}`,
    type: ['VerifiableCredential', 'OpenBadgeCredential'],
    issuer: { id: ISSUER_ID, type: ['Profile'], name: 'Example University' },
    validFrom: now.toISOString().replace('.000Z', 'Z'),
    name: 'NET101 Course Completion',
    credentialSubject: {
      id: `urn:uuid:${randomUUID()}`,
      type: ['AchievementSubject'],
      identifier: [{ type: 'IdentityObject', identityHash: 'sha256$' + createHash('sha256').update(email + salt).digest('hex'),
                     identityType: 'emailAddress', hashed: true, salt }],
      achievement: {
        id: `${BASE}/achievements/net101`, type: ['Achievement'], achievementType: 'Course',
        name: 'مقدمة في الشبكات NET101',
        description: 'أتم الطالب مقرر مقدمة في الشبكات بنجاح',
        criteria: { narrative: 'اجتياز اختبار الوحدة الأولى بدرجة 70% أو أكثر وتسليم المشروع النهائي' }
      }
    },
    credentialSchema: [{ id: 'https://purl.imsglobal.org/spec/ob/v3p0/schema/json/ob_v3p0_achievementcredential_schema.json',
                         type: '1EdTechJsonSchemaValidator2019' }]
  };
  // 1EdTech OB 3.0 section 8.2.4: iss, jti, nbf, sub mirror issuer.id, id, validFrom, credentialSubject.id
  const jwt = await new SignJWT({ ...credential })
    .setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: KID })
    .setIssuer(ISSUER_ID).setJti(credential.id).setSubject(credential.credentialSubject.id)
    .setNotBefore(Math.floor(now.getTime() / 1000))
    .sign(await importJWK(priv, 'RS256'));
  return { credential, jwt };
}

if (process.argv[2] === 'issue') {
  const { credential, jwt } = await issue(process.argv[3]);
  writeFileSync('/out/credential.json', JSON.stringify(credential, null, 2));
  writeFileSync('/out/badge.jwt', jwt);
  console.log(`issued ${credential.id} for ${process.argv[3]}, JWS length ${jwt.length}`);
} else {
  const { pub } = await key();
  createServer((req, res) => {
    const send = (o) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
    if (req.url === '/issuers/1') return send({ '@context': 'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json', id: ISSUER_ID, type: 'Profile', name: 'Example University', url: 'https://example.edu' });
    if (req.url === '/issuers/1/keys/1') return send(pub);
    res.writeHead(404); res.end();
  }).listen(8080, () => console.log(`issuer profile ${ISSUER_ID}, key ${KID}`));
}

في الإعداد أعلاه لاحظ التالي:

  • المفتاح الخاص لا يخرج من الجهة المصدرة: يحفظ في Volume اسمه /data، والذي ينشر على الشبكة هو المفتاح العام فقط على الرابط /issuers/1/keys/1، وفي الإنتاج يكون المفتاح الخاص في وحدة حماية المفاتيح HSM أو خدمة إدارة مفاتيح KMS، لأن من يملكه يستطيع أن يصدر أي شهادة باسم الجامعة.
  • الحقل kid في رأس التوقيع: يخبر المتحقق أين يجد المفتاح العام، والمواصفة تشترط أن يكون رابطاً يعيد المفتاح بصيغة JWK.
  • حقول JWT: القسم 8.2.4 من المواصفة يطلب أن تكرر iss وjti وnbf وsub قيم issuer.id وid وvalidFrom وcredentialSubject.id، لذلك وضعناها بالدوال setIssuer وما بعدها.
  • البريد بالهاش: مثل Moodle، لا يظهر البريد في الشهادة، وإنما SHA-256 له مع ملح عشوائي لكل شهادة.

الآن نشغل الجهة المصدرة في شبكة Docker خاصة بالمختبر، ثم نصدر شهادة للطالب:

docker network create edu5-vc
docker volume create edu5-vc-keys
mkdir -p out
docker run -d --name edu5-vc-issuer --network edu5-vc -m 256m -v "$PWD":/app:ro -v edu5-vc-keys:/data -w /app node:24-alpine node issuer.mjs
docker run --rm --network edu5-vc -m 256m -v "$PWD":/app:ro -v "$PWD/out":/out -v edu5-vc-keys:/data -w /app node:24-alpine \
  node issuer.mjs issue [email protected]
issued urn:uuid:a38ef82a-66e3-4ad7-a027-f823c14d7412 for [email protected], JWS length 2354

الملف out/badge.jwt هو الشهادة كاملة في 2354 حرفاً، وهي ثلاثة أجزاء بينها نقطة، والصورة التالية تبين ما في كل جزء، وما يفعله المتحقق به:

مخطط الشهادة الموقعة VC-JWT: الرأس Header وفيه alg RS256 وtyp JWT وkid رابط المفتاح العام، والمحتوى Payload وفيه type VerifiableCredential وOpenBadgeCredential والجهة المصدرة وتاريخ validFrom وهوية الطالب بالهاش والإنجاز NET101 ورابط المخطط الرسمي وحقول iss وsub وjti وnbf، والتوقيع Signature وهو RSA-SHA256 للجزأين الأولين، وعلى اليمين خمس خطوات للمتحقق: الجهة في قائمة الموثوقين وkid على نطاقها، والتوقيع صحيح، وحقول JWT تطابق الشهادة، والبنية مطابقة للمخطط JSON Schema من 1EdTech، والحامل صاحب البريد المخزن بالهاش، وفي الإنتاج فحص الإلغاء والانتهاء
الرأس يقول أين المفتاح، والمحتوى هو الشهادة، والتوقيع يربطهما، والمتحقق يمر بخمس خطوات

المتحقق verify.mjs

المتحقق جهة أخرى تماماً، لا تملك إلا الملف والبريد الذي قاله الطالب، وقائمة بالجهات المصدرة التي تثق فيها:

// A verifier (another university or an employer): checks a Compact JWS OpenBadgeCredential step by step.
import { decodeProtectedHeader, decodeJwt, importJWK, jwtVerify } from 'jose';
import Ajv2019 from 'ajv/dist/2019.js';
import addFormats from 'ajv-formats';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';

const file = process.argv[2], email = process.argv[3];
const TRUSTED_ISSUERS = (process.env.TRUSTED_ISSUERS || '').split(',').filter(Boolean);
const jwt = readFileSync(file, 'utf8').trim();
const ok = (m) => console.log(`  [OK]   ${m}`), fail = (m) => { console.log(`  [FAIL] ${m}`); process.exit(1); };

console.log(`verifying ${file}`);
const header = decodeProtectedHeader(jwt);
const unverified = decodeJwt(jwt);
// 1. The key: only from a kid under the issuer's own address, never from a jwk the token carries itself
const issuerId = unverified.issuer?.id;
if (!TRUSTED_ISSUERS.includes(issuerId)) fail(`issuer ${issuerId} is not in the trusted list`);
if (!header.kid) fail('no kid in the JOSE header (an embedded jwk proves nothing about the issuer)');
if (!header.kid.startsWith(new URL(issuerId).origin + '/')) fail(`kid ${header.kid} is not published by the issuer ${issuerId}`);
const jwk = await (await fetch(header.kid)).json();
ok(`public key fetched from ${header.kid} (${jwk.kty}, ${header.alg})`);
// 2. The signature
let payload;
try { ({ payload } = await jwtVerify(jwt, await importJWK(jwk, header.alg), { algorithms: ['RS256'] })); }
catch (e) { fail(`signature: ${e.code} ${e.message}`); }
ok('signature valid');
// 3. JWT claims must match the credential (OB 3.0, 8.2.6.1)
if (payload.iss !== payload.issuer.id) fail('iss != issuer.id');
if (payload.sub !== payload.credentialSubject.id) fail('sub != credentialSubject.id');
if (payload.jti !== payload.id) fail('jti != id');
if (new Date(payload.nbf * 1000).toISOString().replace('.000Z', 'Z') !== payload.validFrom) fail('nbf != validFrom');
ok('iss, sub, jti and nbf match the credential');
// 4. The structure against the official 1EdTech JSON Schema
const schemaUrl = payload.credentialSchema[0].id;
const schema = await (await fetch(schemaUrl)).json();
const ajv = new Ajv2019({ strict: false, allErrors: true }); addFormats(ajv);
const credential = { ...payload }; for (const c of ['iss', 'sub', 'jti', 'nbf', 'exp', 'iat']) delete credential[c];
if (!ajv.validate(schema, credential)) fail('schema: ' + ajv.errorsText());
ok(`valid against ${schemaUrl.split('/').pop()}`);
// 5. The recipient: the holder proves the email the issuer hashed
if (email) {
  const id = payload.credentialSubject.identifier[0];
  const h = 'sha256$' + createHash('sha256').update(email + id.salt).digest('hex');
  if (h !== id.identityHash) fail(`recipient: ${email} does not match the hashed identifier`);
  ok(`recipient ${email} matches the hashed identifier`);
}
console.log(`RESULT: valid "${payload.credentialSubject.achievement.name}" issued by ${payload.issuer.name} on ${payload.validFrom}`);
docker run --rm --network edu5-vc -m 256m -e TRUSTED_ISSUERS=http://edu5-vc-issuer:8080/issuers/1 \
  -v "$PWD":/app:ro -v "$PWD/out":/out -w /app node:24-alpine node verify.mjs /out/badge.jwt [email protected]

والمخرج سوف يكون كما يلي:

verifying /out/badge.jwt
  [OK]   public key fetched from http://edu5-vc-issuer:8080/issuers/1/keys/1 (RSA, RS256)
  [OK]   signature valid
  [OK]   iss, sub, jti and nbf match the credential
  [OK]   valid against ob_v3p0_achievementcredential_schema.json
  [OK]   recipient [email protected] matches the hashed identifier
RESULT: valid "مقدمة في الشبكات NET101" issued by Example University on 2026-10-21T08:49:11Z

الخطوات الخمس نجحت، ولاحظ أن الخطوة الرابعة جلبت المخطط الرسمي ob_v3p0_achievementcredential_schema.json من موقع 1EdTech وفحصت الشهادة عليه، فلو نسينا حقلاً إلزامياً مثل achievement.criteria لظهر الخطأ هنا. وإذا جرب شخص آخر الشهادة نفسها ببريد غير بريد الطالب:

verifying /out/badge.jwt
  [OK]   public key fetched from http://edu5-vc-issuer:8080/issuers/1/keys/1 (RSA, RS256)
  [OK]   signature valid
  [OK]   iss, sub, jti and nbf match the credential
  [OK]   valid against ob_v3p0_achievementcredential_schema.json
  [FAIL] recipient: [email protected] does not match the hashed identifier

الشهادة صحيحة ولكنها ليست له، وهذا الفرق مهم: صحة التوقيع تثبت أن الجامعة أصدرت الشهادة، ومطابقة الهوية تثبت أن من يعرضها هو صاحبها.

نجرب التزوير: ماذا يحدث إذا…؟

السكربت التالي يأخذ الشهادة الحقيقية، ويحاول ثلاث طرق، وكلها تحول «مقدمة في الشبكات» إلى «ماجستير في الشبكات»:

// Two attacks on a credential: edit one field of a real badge, and sign a fake badge with the attacker's own key.
import { generateKeyPair, exportJWK, SignJWT, decodeJwt } from 'jose';
import { readFileSync, writeFileSync } from 'node:fs';
const jwt = readFileSync('/out/badge.jwt', 'utf8').trim();
// 1. Tampering: change the achievement name inside the payload and keep the original signature
const [h, p, s] = jwt.split('.');
const body = JSON.parse(Buffer.from(p, 'base64url'));
body.credentialSubject.achievement.name = 'ماجستير في الشبكات';
writeFileSync('/out/tampered.jwt', [h, Buffer.from(JSON.stringify(body)).toString('base64url'), s].join('.'));
// 2. Forgery: a key the attacker controls, the same issuer id, the key embedded in the header or hosted elsewhere
const { privateKey, publicKey } = await generateKeyPair('RS256', { extractable: true });
const fake = decodeJwt(jwt);
fake.credentialSubject.achievement.name = 'ماجستير في الشبكات';
writeFileSync('/out/forged-jwk.jwt', await new SignJWT(fake).setProtectedHeader({ alg: 'RS256', typ: 'JWT', jwk: await exportJWK(publicKey) }).sign(privateKey));
writeFileSync('/out/forged-kid.jwt', await new SignJWT(fake).setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: 'http://attacker.example:8080/key' }).sign(privateKey));
console.log('wrote tampered.jwt, forged-jwk.jwt, forged-kid.jwt');

والنتيجة عند المتحقق لكل ملف كانت كما يلي، وأولها التعديل مع إبقاء التوقيع الأصلي:

verifying /out/tampered.jwt
  [OK]   public key fetched from http://edu5-vc-issuer:8080/issuers/1/keys/1 (RSA, RS256)
  [FAIL] signature: ERR_JWS_SIGNATURE_VERIFICATION_FAILED signature verification failed

ثم شهادة موقعة بمفتاح المزور، والمفتاح العام نفسه موضوع في رأس التوقيع في الحقل jwk:

verifying /out/forged-jwk.jwt
  [FAIL] no kid in the JOSE header (an embedded jwk proves nothing about the issuer)

ثم شهادة موقعة بمفتاح المزور، والمفتاح منشور على سيرفر يملكه:

verifying /out/forged-kid.jwt
  [FAIL] kid http://attacker.example:8080/key is not published by the issuer http://edu5-vc-issuer:8080/issuers/1

وقد يتساءل البعض: إذا كانت المواصفة نفسها تسمح بوضع المفتاح العام في الحقل jwk، فلماذا رفضه المتحقق؟ والإجابة في التجربة التالية، فهذا متحقق مستعجل يقبل أي مفتاح يأتي مع الشهادة، بالخيار EmbeddedJWK من مكتبة jose:

// A careless verifier: trusts whatever key the token carries in its own header (jose EmbeddedJWK).
import { jwtVerify, EmbeddedJWK } from 'jose';
import { readFileSync } from 'node:fs';
const jwt = readFileSync(process.argv[2], 'utf8').trim();
try {
  const { payload } = await jwtVerify(jwt, EmbeddedJWK);
  console.log(`naive verifier: VALID "${payload.credentialSubject.achievement.name}" from ${payload.issuer.id}`);
} catch (e) { console.log(`naive verifier: ${e.code}`); }
naive verifier: VALID "ماجستير في الشبكات" from http://edu5-vc-issuer:8080/issuers/1

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

الشهادة داخل صورة الشارة Baking

المواصفة في القسم 5.3.1 تشرح كيف توضع الشهادة داخل صورة PNG: قطعة iTXt اسمها openbadgecredential، ونصها الشهادة كما هي دون ضغط. والسكربت التالي يفعل ذلك ويعكسه:

// Baking (Open Badges 3.0, section 5.3.1): put the Compact JWS into an iTXt chunk named "openbadgecredential"
// of the badge PNG, uncompressed; and extract it back from any PNG.
import { readFileSync, writeFileSync } from 'node:fs';
import { crc32 } from 'node:zlib';

const chunk = (type, data) => {
  const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
  const td = Buffer.concat([Buffer.from(type, 'latin1'), data]);
  const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td) >>> 0);
  return Buffer.concat([len, td, crc]);
};
function* chunks(png) {
  for (let i = 8; i < png.length;) {
    const len = png.readUInt32BE(i), type = png.toString('latin1', i + 4, i + 8);
    yield { type, data: png.subarray(i + 8, i + 8 + len), at: i };
    i += 12 + len;
  }
}
const [cmd, src, dst] = process.argv.slice(2);
const png = readFileSync(src);
if (cmd === 'bake') {
  const jws = readFileSync('/out/badge.jwt', 'utf8').trim();
  // keyword \0 compression-flag(0) compression-method(0) language-tag \0 translated-keyword \0 text
  const data = Buffer.concat([Buffer.from('openbadgecredential\0', 'latin1'), Buffer.from([0, 0]), Buffer.from('\0\0'), Buffer.from(jws, 'utf8')]);
  const iend = [...chunks(png)].find(c => c.type === 'IEND').at;
  writeFileSync(dst, Buffer.concat([png.subarray(0, iend), chunk('iTXt', data), png.subarray(iend)]));
  console.log(`baked ${jws.length} characters into ${dst} (${png.length} -> ${readFileSync(dst).length} bytes)`);
} else {
  const c = [...chunks(png)].find(c => c.type === 'iTXt' && c.data.toString('latin1').startsWith('openbadgecredential\0'));
  if (!c) { console.log('no openbadgecredential chunk'); process.exit(1); }
  const text = c.data.subarray('openbadgecredential\0'.length + 4).toString('utf8');
  writeFileSync(dst, text);
  console.log(`extracted ${text.length} characters from ${src} to ${dst}`);
}

دمجنا الشهادة في صورة الشارة نفسها التي رفعناها إلى Moodle، ثم استخرجناها من الصورة وقارناها بالأصل وفحصناها:

docker run --rm --network edu5-vc -m 256m -e TRUSTED_ISSUERS=http://edu5-vc-issuer:8080/issuers/1 \
  -v "$PWD":/app:ro -v "$PWD/out":/out -w /app node:24-alpine sh -c \
  'node bake.mjs bake /out/net101.png /out/net101-baked.png && node bake.mjs extract /out/net101-baked.png /out/extracted.jwt && cmp /out/extracted.jwt /out/badge.jwt && echo same && node verify.mjs /out/extracted.jwt [email protected]'
baked 2354 characters into /out/net101-baked.png (20286 -> 22676 bytes)
extracted 2354 characters from /out/net101-baked.png to /out/extracted.jwt
same
verifying /out/extracted.jwt
  [OK]   public key fetched from http://edu5-vc-issuer:8080/issuers/1/keys/1 (RSA, RS256)
  [OK]   signature valid
  [OK]   iss, sub, jti and nbf match the credential
  [OK]   valid against ob_v3p0_achievementcredential_schema.json
  [OK]   recipient [email protected] matches the hashed identifier
RESULT: valid "مقدمة في الشبكات NET101" issued by Example University on 2026-10-21T08:49:11Z

زاد حجم الصورة 2390 بايتاً فقط، والصورة تفتح في أي برنامج كأي صورة، ولكن البرنامج الذي يعرف المعيار يستخرج منها الشهادة ويتحقق منها، فإذا وضعها الطالب في ملفه على LinkedIn أو أرسلها بالبريد فالشهادة معها.

ما الذي ينقص التجربة لتصبح نظاماً حقيقياً؟

التجربة أعلاه تبين الفكرة كاملة، ولكن لا تقم بتشغيلها في الإنتاج كما هي، والسبب أن الجامعة تحتاج فوقها ما يلي:

الجزءفي التجربةفي الإنتاج
المفتاح الخاص Private Keyملف في VolumeHSM أو KMS، مع إجراء مكتوب لتدوير المفتاح Key Rotation دون أن تبطل الشهادات القديمة
هوية الجهة المصدرةعنوان داخل شبكة Dockerنطاق دائم للجامعة بشهادة TLS، لأن تغييره يقطع الطريق إلى المفتاح
الإلغاء Revocationغير موجودقائمة Bitstring Status List منشورة، ورقم لكل شهادة فيها، وفحصها عند المتحقق
مصدر البياناتبيانات مكتوبة في الكودنظام شؤون الطلاب SIS بعد اعتماد النتائج، كما في مقال نظام شؤون الطلاب المفتوح المصدر
استلام الطالب للشهادةملفتطبيق محفظة مثل Learner Credential Wallet المفتوح المصدر بترخيص MIT
التحقق من المطابقةالمخطط الرسمي فقطالمدقق العام من 1EdTech digital-credentials-public-validator المفتوح المصدر بترخيص Apache-2.0

وتذكر أن الشهادة فيها بيانات شخصية، حتى مع إخفاء البريد بالهاش، فالاسم والإنجاز والتاريخ تظهر لكل من يراها، لذلك اكتب سياسة واضحة لما تضعه فيها، وفي السعودية يحكم ذلك نظام حماية البيانات الشخصية.

أخطاء شائعة

  • قبول المفتاح المرفق بالشهادة: كما رأينا، المتحقق الذي يقبل jwk من رأس التوقيع دون ربطه بالجهة يقبل أي شهادة مزورة.
  • تحقق دون قائمة جهات موثوقة: التوقيع الصحيح من «جامعة» لا تعرفها لا يعني شيئاً، فمصنع الشهادات يستطيع أن يوقع شهاداته بمفتاحه وعلى نطاقه.
  • إصدار الشارات قبل استقرار العنوان: في Moodle تدخل قيمة wwwroot في كل رابط داخل ملف الشارة، وفي الشهادة الموقعة يدخل نطاق الجهة المصدرة في kid.
  • وضع البريد صريحاً: استخدم identifier بالهاش والملح، فالشهادة تنتشر أكثر مما تتوقع.
  • نسيان الإلغاء: الشهادة التي تمنح بالخطأ ستبقى صحيحة إلى الأبد إذا لم يوجد طريق لإلغائها.

تدريب عملي

  • أضف إلى الشهادة الحقل validUntil بعد سنة، وأضف إلى المتحقق فحص exp كما في القسم 8.2.6.1 من المواصفة، ثم أصدر شهادة منتهية وتأكد أنها ترفض.
  • غير اسم المفتاح في kid إلى keys/2 وولد مفتاحاً جديداً، ثم تأكد أن الشهادات القديمة الموقعة بالمفتاح الأول ما زالت تتحقق، وهذا هو تدوير المفتاح.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • ملف PDF ورمز QR لا يثبتان الشهادة، والذي يثبتها توقيع رقمي من الجهة المصدرة بمفتاح منشور على نطاقها.
  • Open Badges 3.0 يجعل الشارة شهادة قابلة للتحقق بنموذج W3C 2.0، موقعة بالصيغة VC-JWT أو بتوقيع مدمج، ويمكن دمجها في صورة PNG.
  • Moodle يصدر اليوم شارات Open Badges 2 بالتحقق المستضاف، والإصدار 3.0 ما زال قيد التطوير في سجل مشكلاته.
  • المتحقق الصحيح يمر بخمس خطوات، وأولها ربط المفتاح بجهة موثوقة، وبدونها قبل متحقق مستعجل في تجربتنا شهادة «ماجستير» مزورة.
  • للإنتاج تحتاج حماية للمفتاح الخاص، ونطاقاً دائماً، وقائمة إلغاء، وربطاً بنظام شؤون الطلاب.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال وتجربته على Moodle 5.2.4 وNode.js 24 مع jose 6.2.12 وAjv 8.20.0، ومراجعة مواصفة Open Badges 3.0 (الوثيقة 1.4.5).
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.