عندما يتقدم خريج إلى وظيفة، ترسل جهة العمل عادة بريداً إلى عمادة القبول والتسجيل تسأل فيه: هل هذه الشهادة صحيحة؟ ثم تنتظر أياماً، والعمادة نفسها تبحث في النظام وترد يدوياً، وهذا العمل يتكرر آلاف المرات في السنة، ومع ذلك فالملف PDF الذي يحمله الخريج يمكن لأي شخص أن يعدله في دقائق. في مقال الأنظمة التي تحتاجها الجامعة وصلنا إلى مرحلة التخرج والخريجين، وفي هذا المقال سوف نأخذ جزءاً واحداً منها: الشهادة الرقمية التي يتحقق منها أي شخص بنفسه، دون أن يتصل بالجامعة، وسوف نتناول:
- لماذا لا تكفي الشهادة PDF ولا رمز QR يفتح صفحة في موقع الجامعة.
- نموذج الشهادات القابلة للتحقق Verifiable Credentials من W3C، ومعيار الشارات المفتوحة Open Badges 3.0 المبني عليه.
- ما الذي يقدمه Moodle اليوم في الشارات Badges، مع شارة حقيقية أصدرناها من مقرر وقرأنا ملفها.
- تجربة كاملة: جامعة تصدر شهادة موقعة، ومتحقق يفحصها خطوة خطوة، ثم ثلاث محاولات تزوير، ودمج الشهادة داخل صورة الشارة.
لماذا لا يكفي ملف PDF؟
الحل البسيط هو أن تصدر الجامعة الشهادة ملفاً PDF عليه شعارها وتوقيع العميد، وهذا ما تفعله أغلب الجامعات، ومشكلته أن الملف صورة لشهادة وليس دليلاً عليها، فلا يوجد فيه شيء يمنع تعديل الاسم أو التقدير. والحل التالي هو رمز QR في الملف يفتح صفحة تحقق في موقع الجامعة، وهو أفضل، ولكن له ثلاث مشكلات: يعمل ما دامت الصفحة موجودة، فإذا تغير النظام أو النطاق تعطلت كل الروابط القديمة، وكل عملية تحقق تمر بسيرفر الجامعة فتعرف الجامعة من يتحقق من شهادات خريجيها ومتى، والأخطر أن المزور يستطيع أن يضع رمز QR يفتح صفحة في نطاق يشبه نطاق الجامعة.
والحل الذي وصلت إليه المعايير الحديثة هو التوقيع الرقمي Digital Signature: الجامعة توقع بيانات الشهادة بمفتاحها الخاص Private Key، وتنشر مفتاحها العام Public Key مرة واحدة على نطاقها، وأي متحقق يجلب المفتاح العام ويتأكد بنفسه أن البيانات لم تتغير حرفاً واحداً منذ وقعتها الجامعة، وهي الفكرة نفسها التي تعمل بها شهادات TLS في المتصفح.
الشهادة القابلة للتحقق Verifiable Credential
نموذج الشهادات القابلة للتحقق هو معيار من W3C، وصل إصداره الثاني Verifiable Credentials Data Model 2.0 إلى مرحلة التوصية W3C Recommendation في 15 مايو 2025، وهو لا يخص التعليم وحده، فيمكن أن تكون الشهادة رخصة قيادة أو بطاقة موظف، ولكن الجامعات من أكثر من يستفيد منه. والنموذج فيه ثلاثة أطراف كما في الصورة التالية:

- الجهة المصدرة 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، ومنحناها للطالب خالد، والصورة التالية لصفحة الشارة كما يراها أي شخص معه الرابط:

ولكن الصفحة ليست الشارة، فالشارة نفسها ملف 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 لفحص البنية:
- الجهة المصدرة: سيرفر صغير يولد مفتاح RSA للجامعة مرة واحدة، وينشر ملفها التعريفي Profile ومفتاحها العام، ويصدر شهادة
OpenBadgeCredentialموقعة بالصيغة VC-JWT. - المتحقق: سكربت يفحص الشهادة بالترتيب الذي تحدده المواصفة.
- المزور: سكربت يحاول ثلاث طرق للتزوير، لنرى أين يقف كل منها.
أنشئ مجلداً للمختبر وفيه الملف 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 حرفاً، وهي ثلاثة أجزاء بينها نقطة، والصورة التالية تبين ما في كل جزء، وما يفعله المتحقق به:

المتحقق 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 | ملف في Volume | HSM أو 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).