توقيع البيانات والتحقّق منها باستخدام WebCrypto

ميزات الويب: تشفير الويب

استخدِم واجهة برمجة التطبيقات Web Cryptography API‏ (crypto.subtle) لتوقيع الرسائل أو الرموز المميّزة أو العناصر والتحقّق منها في المتصفّح باستخدام مفاتيح غير متماثلة مقاومة لهجمات كمبيوتر الكم (ML-DSA) أو كلاسيكية ('Ed25519'، 'ECDSA'). تضمن التوقيعات الرقمية صحة البيانات وأصالة الموقّع بدون الحاجة إلى مفاتيح سرية مشتركة. لإنشاء رموز سرية مشتركة وتشفير الحمولات السرية، يُرجى الاطّلاع على تشفير البيانات من جهة العميل باستخدام WebCrypto.

1. اختيار خوارزمية وإنشاء مفتاحَي تشفير

توفّر خوارزمية ML-DSA (خوارزمية التوقيع الرقمي المعتمدة على الشبكة الوحدة، والمعتمدة في معيار NIST FIPS 204) توقيعات رقمية مستندة إلى الشبكة الوحدة ما بعد التشفير الكمّي ومقاومة للهجمات من أجهزة الكمبيوتر الكلاسيكية والكمّية.

استخدِم 'ML-DSA-65' تلقائيًا للتوقيع العام. اختَر 'ML-DSA-44' عندما تحتاج إلى مفاتيح عامة وتوقيعات أصغر حجمًا عبر قنوات ذات نطاق ترددي محدود ويكون مستوى الأمان 128 بت كافيًا، أو 'ML-DSA-87' عندما تتطلب متطلبات السياسات الامتثال للفئة 5 من المعهد الوطني للمعايير والتكنولوجيا (NIST) (الإصدار 2.0 من مجموعة خوارزميات الأمان الوطني التجاري (CNSA)).

اسم الخوارزمية مستوى الأمان وحالة الاستخدام المفتاح العام ('raw-public') حجم التوقيع
'ML-DSA-44' الفئة 2 من المعهد الوطني للمعايير والتكنولوجيا (NIST) (أمان يبلغ 128 بت تقريبًا)، أصغر المفاتيح والتوقيعات ‫1312 بايت ‫2420 بايت
'ML-DSA-65' الفئة 3 من المعهد الوطني للمعايير والتكنولوجيا (أمان يبلغ 192 بت تقريبًا)، وهو الإعداد التلقائي المقترَح ‫1952 بايت ‫3309 بايت
'ML-DSA-87' الفئة 5 من المعهد الوطني للمعايير والتكنولوجيا (أمان يبلغ 256 بت تقريبًا)، وهو أعلى هامش أمان ‫2592 بايت ‫4,627 بايت
  • يجب تمرير 'ML-DSA-44' أو 'ML-DSA-65' أو 'ML-DSA-87' كاسم الخوارزمية (الأسماء التي تسبق عملية التوحيد، مثل 'Dilithium' أو 'CRYSTALS-Dilithium'، تؤدي إلى ظهور الخطأ NotSupportedError).
  • لا تتطلّب خوارزمية ML-DSA أو 'Ed25519' مَعلمة hash.
  • مرِّر ['sign', 'verify'] عند إنشاء مفتاحَي تشفير باستخدام generateKey():
const signerKeyPair = await crypto.subtle.generateKey(
  'ML-DSA-65',
  false,
  ['sign', 'verify'],
);

2. تصدير المفاتيح واستيرادها

'ML-DSA-44' و'ML-DSA-65' و'ML-DSA-87' ترفض تنسيق 'raw' القديم في importKey() وexportKey() مع NotSupportedError. يجب دائمًا تصدير المفاتيح أو استيرادها باستخدام تنسيق واضح:

  • ‫'raw-public': وحدات بايت المفتاح العام (1312 أو 1952 أو 2592 بايت). يجب تمرير ['verify'] كاستخدام للمفتاح عند استيراد مفتاح عام.
  • 'raw-seed': مفتاح خاص عشوائي بحجم 32 بايت لجميع خوارزميات ML-DSA الثلاث. يؤدي كل من 'raw' و'raw-private' إلى ظهور الخطأ NotSupportedError في ML-DSA. يمكنك تخزين المفاتيح الخاصة أو نقلها باستخدام 'raw-seed' أو 'pkcs8' (54 بايت) أو 'jwk' (kty: 'AKP'). مرِّر ['sign'] كاستخدام للمفتاح عند استيراد مفتاح خاص (يؤدي تمرير ['sign', 'verify'] إلى importKey() إلى ظهور الخطأ SyntaxError).
  • ‫'spki' و'pkcs8' و'jwk': تتوفّر لجميع خوارزميات ML-DSA الثلاث.

يجب دائمًا تصدير المفتاح العام للتحقّق باستخدام 'raw-public' حتى يمكن تسلسل وحدات بايت المفتاح العام أو مشاركتها مع جهات التحقّق:

// Export the public key bytes on the signer
const publicKeyBytes = await crypto.subtle.exportKey(
  'raw-public',
  signerKeyPair.publicKey,
);

// Import the public key bytes on the verifier (usage must be ['verify'] only)
const verifierPublicKey = await crypto.subtle.importKey(
  'raw-public',
  publicKeyBytes,
  'ML-DSA-65',
  true,
  ['verify'],
);

إذا كان لديك CryptoKey توقيع خاص فقط (حتى مع extractable: false) وكنت بحاجة إلى CryptoKey العام المقابل له، يمكنك استدعاء crypto.subtle.getPublicKey() باستخدام ['verify']:

const publicKey = await crypto.subtle.getPublicKey(
  signerKeyPair.privateKey,
  ['verify'],
);

3- توقيع الحمولات والتحقّق منها

استخدِم الدالة crypto.subtle.sign() مع المفتاح الخاص لإنشاء توقيع، والدالة crypto.subtle.verify() مع المفتاح العام للتحقّق منه:

const payloadBytes = new TextEncoder().encode(
  '{"artifact":"release-v2.4.0.tar.gz","sha256":"9f86d08..."}',
);

const signatureBuffer = await crypto.subtle.sign(
  'ML-DSA-65',
  signerKeyPair.privateKey,
  payloadBytes,
);

const isValid = await crypto.subtle.verify(
  'ML-DSA-65',
  verifierPublicKey,
  signatureBuffer,
  payloadBytes,
);

فصل النطاقات باستخدام ContextParams

لمنع إعادة تشغيل توقيع تم إنشاؤه لغرض معيّن في جزء آخر من تطبيقك أو بروتوكولك الذي يشارك مفتاحَي التشفير نفسهما، اربط التوقيع context بفصل النطاق.

بدلاً من سلسلة اسم خوارزمية بسيطة، يمكنك تمرير قاموس ContextParams ({ name, context }) إلى sign() وverify()، حيث يكون context هو BufferSource (Uint8Array أو ArrayBuffer):

const contextBytes = new TextEncoder().encode('release-manifest-v1');

const signatureBuffer = await crypto.subtle.sign(
  {
    name: 'ML-DSA-65',
    context: contextBytes,
  },
  signerKeyPair.privateKey,
  payloadBytes,
);

const isValid = await crypto.subtle.verify(
  {
    name: 'ML-DSA-65',
    context: contextBytes,
  },
  verifierPublicKey,
  signatureBuffer,
  payloadBytes,
);
  • الحدّ الأقصى هو 255 بايت: تقبل السمة context من 0 إلى 255 بايت (يؤدي حذف السمة context إلى تمرير سمة new Uint8Array(0) فارغة). ويؤدي تمرير أكثر من 255 بايت إلى رفض السمة مع ظهور الخطأ OperationError.
  • يجب أن تكون هناك مطابقة تامة: لا يعرض crypto.subtle.verify() القيمة true إلا عندما يمرّر المدقّق وحدات البايت context نفسها التي تم استخدامها أثناء crypto.subtle.sign().

استراتيجيات احتياطية

إذا كان هدفك الأساسي لا يتيح استخدام SubtleCrypto.supports() أو ML-DSA، يمكنك رصد مدى توفّر هذه الميزة باستخدام طريقة SubtleCrypto.supports() الثابتة والعودة إلى استخدام 'Ed25519' الكلاسيكية (أو 'ECDSA' مع 'P-256' و'SHA-256' عند استهداف المتصفّحات قبل إتاحة استخدام 'Ed25519'):

  • يجب استدعاء SubtleCrypto.supports() في الدالة الإنشائية SubtleCrypto، وليس في crypto.subtle: globalThis.SubtleCrypto?.supports?.('sign', 'ML-DSA-65') هي طريقة ثابتة متزامنة تعرض قيمة منطقية (crypto.subtle.supports هي undefined). يجب فحص 'sign' أو 'verify' أو 'generateKey' أو 'importKey' أو 'exportKey' أو 'getPublicKey' (أو تمرير قاموس ContextParams مثل SubtleCrypto.supports('sign', { name: 'ML-DSA-65', context })).
  • تنسيق المفتاح في المسار الاحتياطي ('raw'): على الرغم من أنّ المتصفّحات التي تتوافق مع Modern WebCrypto تقبل أيضًا 'raw-public' على 'Ed25519' و'ECDSA'، استخدِم 'raw' في المسارات الاحتياطية التي تستهدف المتصفّحات القديمة.
  • فصل النطاقات في المسار الاحتياطي: لا تقرأ خوارزميات WebCrypto الكلاسيكية ('Ed25519' و'ECDSA') سمة context من قاموس الخوارزميات (يتجاهل WebIDL تلقائيًا سمات القاموس غير المعروفة في Algorithm وEcdsaParams). أضِف عنوان context مسبوقًا بالطول إلى الحمولة في المسار الاحتياطي لكي يعمل فصل النطاقات بشكل متّسق في كلا المجموعتَين.
const ML_DSA_PUBLIC_KEY_ALGORITHMS = {
  1312: 'ML-DSA-44',
  1952: 'ML-DSA-65',
  2592: 'ML-DSA-87',
};

export function supportsPostQuantumSignatures() {
  return (
    typeof globalThis.SubtleCrypto?.supports === 'function' &&
    SubtleCrypto.supports('sign', 'ML-DSA-65')
  );
}

export async function generateSigningKeyPair() {
  if (supportsPostQuantumSignatures()) {
    const keyPair = await crypto.subtle.generateKey('ML-DSA-65', false, ['sign', 'verify']);
    const publicKeyBytes = new Uint8Array(
      await crypto.subtle.exportKey('raw-public', keyPair.publicKey),
    );
    return {
      suite: 'ML-DSA-65',
      keyFormat: 'raw-public',
      keyPair,
      publicKeyBytes,
    };
  }

  // Classical fallback: Ed25519
  const keyPair = await crypto.subtle.generateKey('Ed25519', false, ['sign', 'verify']);
  const publicKeyBytes = new Uint8Array(await crypto.subtle.exportKey('raw', keyPair.publicKey));
  return {
    suite: 'Ed25519',
    keyFormat: 'raw',
    keyPair,
    publicKeyBytes,
  };
}

function frameFallbackInput(payloadBytes, contextBytes) {
  if (contextBytes.byteLength > 255) {
    throw new DOMException('Context must not exceed 255 bytes', 'OperationError');
  }
  const framed = new Uint8Array(1 + contextBytes.byteLength + payloadBytes.byteLength);
  framed[0] = contextBytes.byteLength;
  framed.set(contextBytes, 1);
  framed.set(payloadBytes, 1 + contextBytes.byteLength);
  return framed;
}

export async function signPayload(
  privateKey,
  payloadBytes,
  contextBytes = new Uint8Array(0),
) {
  if (privateKey.algorithm.name.startsWith('ML-DSA')) {
    const sig = await crypto.subtle.sign(
      { name: privateKey.algorithm.name, context: contextBytes },
      privateKey,
      payloadBytes,
    );
    return new Uint8Array(sig);
  }

  const framed = frameFallbackInput(payloadBytes, contextBytes);
  const sig = await crypto.subtle.sign('Ed25519', privateKey, framed);
  return new Uint8Array(sig);
}

export async function verifyPayload(
  publicKeyOrBytes,
  signatureBytes,
  payloadBytes,
  contextBytes = new Uint8Array(0),
) {
  const publicKey =
    publicKeyOrBytes instanceof CryptoKey
      ? publicKeyOrBytes
      : await (() => {
          const bytes =
            publicKeyOrBytes instanceof Uint8Array
              ? publicKeyOrBytes
              : new Uint8Array(publicKeyOrBytes);
          const mlDsaName = ML_DSA_PUBLIC_KEY_ALGORITHMS[bytes.byteLength];
          return mlDsaName
            ? crypto.subtle.importKey('raw-public', bytes, mlDsaName, true, ['verify'])
            : crypto.subtle.importKey('raw', bytes, 'Ed25519', true, ['verify']);
        })();

  if (publicKey.algorithm.name.startsWith('ML-DSA')) {
    return crypto.subtle.verify(
      { name: publicKey.algorithm.name, context: contextBytes },
      publicKey,
      signatureBytes,
      payloadBytes,
    );
  }

  const framed = frameFallbackInput(payloadBytes, contextBytes);
  return crypto.subtle.verify('Ed25519', publicKey, signatureBytes, framed);
}