تشفير البيانات من جهة العميل باستخدام WebCrypto

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

استخدِم Web Cryptography API‏ (crypto.subtle) لإنشاء مفاتيح سرية مشتركة عبر شبكات غير موثوق بها باستخدام "آليات تغليف المفاتيح" (KEM) المقاومة للحوسبة الكمية أو اتفاقية المفاتيح الكلاسيكية، وتشفير البيانات الحسّاسة في المتصفّح باستخدام "التشفير المصادَق عليه مع البيانات المرتبطة" (AEAD). لتوقيع الرسائل أو العناصر والتحقّق منها بدون تشفيرها، يُرجى الاطّلاع على توقيع البيانات والتحقّق منها باستخدام WebCrypto.

ينقسم تدفق تشفير المغلف الشامل إلى مرحلتين:

  • إعداد المستلِم لمرة واحدة (الخطوتان 1 و2): ينشئ المستلِم مفتاحَي التشفير، ويحفظ CryptoKeyPair في IndexedDB للجلسات المستقبلية، ويصدِّر المفتاح العام ('raw-public') لمشاركته مع المُرسِلين.
  • التشفير لكل رسالة (الخطوتان 3 و4): لكل حمولة، يستورد المُرسِل المفتاح العام للمستلِم، ويستدعي encapsulateKey() لإنشاء مفتاح مشترَك جديد صالح لمرة واحدة وكبسولة نص مشفّر، ويشفّر الحمولة باستخدام متجه إعداد جديد بحجم 12 بايت، ويرسل { capsule, iv, ciphertext } إلى المستلِم.

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

على عكس بروتوكول ديفي-هيلمان لتبادل المفاتيح (deriveKey()) الذي يجمع فيه الطرفان مفاتيحهما، تستخدم آلية تغليف المفاتيح (KEM) المفتاح العام للمستلِم لإنشاء مفتاح سري مشترك عشوائي وكبسولة نص مشفّر في طلب encapsulateKey() واحد. يسترد المستلم المفتاح المشترَك نفسه من كبسولة النص المشفّر باستخدام decapsulateKey().

استخدِم 'MLKEM768-X25519' تلقائيًا: يجمع هذا الإعداد بين خوارزمية ML-KEM لما بعد التشفير الكمّي (الموحّدة في معيار NIST FIPS 203) وخوارزمية 'X25519' الكلاسيكية، وبذلك يظلّ الرمز السري المشترَك آمنًا طالما أنّ أيًا من الخوارزميتَين تعمل. اختَر معيار 'ML-KEM-768' المستقل عندما تتطلّب السياسة مخرجات FIPS 203 فقط بدون مكوّن مختلط، أو 'ML-KEM-1024' عندما تتطلّب السياسة الامتثال للفئة 5 من المعهد الوطني للمعايير والتكنولوجيا (NIST) (الإصدار 2.0 من مجموعة خوارزميات الأمان الوطني التجاري).

اسم الخوارزمية مستوى الأمان وحالة الاستخدام المفتاح العام ('raw-public') قيمة أولية خاصة ('raw-seed') حجم النص المُشفَّر
'MLKEM768-X25519' مختلط بعد التشفير الكمي ('ML-KEM-768') + تقليدي ('X25519')، وهو الإعداد التلقائي المقترَح ‫1216 بايت ‫32 بايت ‫1120 بايت
'ML-KEM-768' الفئة 3 من المعهد الوطني للمعايير والتكنولوجيا (NIST) (أمان يبلغ 192 بت تقريبًا)، وآلية KEM مستقلة لما بعد التشفير الكمي ‫1184 بايت ‫64 بايت ‫1088 بايت
'ML-KEM-1024' الفئة 5 من المعهد الوطني للمعايير والتكنولوجيا (أمان يبلغ 256 بت تقريبًا)، وهو أعلى هامش أمان ‫1568 بايت ‫64 بايت ‫1568 بايت
  • مرِّر 'MLKEM768-X25519' أو 'ML-KEM-768' أو 'ML-KEM-1024' كاسم الخوارزمية (الأسماء المستعارة مثل 'X-Wing' أو 'X25519MLKEM768'، بالإضافة إلى 'ML-KEM-512'، تظهر NotSupportedError).
  • مرِّر extractable: false و['encapsulateKey', 'decapsulateKey'] (أو ['encapsulateBits', 'decapsulateBits']) عند إنشاء مفتاحَي تشفير KEM. يمكن الاحتفاظ بعناصر CryptoKey وCryptoKeyPair غير القابلة للاستخراج على مستوى الجلسات مباشرةً في IndexedDB من خلال الاستنساخ المنظَّم (objectStore.put(recipientKeyPair, 'recipient-kem-key')) بدون تصدير وحدات بايت المفتاح الخاص:
const recipientKeyPair = await crypto.subtle.generateKey(
  'MLKEM768-X25519',
  false,
  ['encapsulateKey', 'decapsulateKey'],
);

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

إنّ خوارزميات WebCrypto الحديثة ('MLKEM768-X25519' و'ML-KEM-768' و'ML-KEM-1024' و'ChaCha20-Poly1305') ترفض تنسيق 'raw' القديم في importKey() وexportKey() مع NotSupportedError. حدِّد دائمًا تنسيق المفتاح الصريح:

  • ‫'raw-public': وحدات بايت المفتاح العام (1216 أو 1184 أو 1568 بايت). يجب تمرير ['encapsulateKey'] (أو ['encapsulateBits']) عند استيراد مفتاح عام لآلية KEM.
  • ‫'raw-seed': وحدات بايت أولية للمفتاح الخاص (32 بايت لـ 'MLKEM768-X25519'، و64 بايت لـ 'ML-KEM-768' و'ML-KEM-1024'). يتم تمرير ['decapsulateKey'] (أو ['decapsulateBits']) عند استيراد مفتاح خاص لآلية KEM (يؤدي تمرير ['encapsulateKey', 'decapsulateKey'] إلى importKey() أو getPublicKey() إلى ظهور SyntaxError). يؤدي كل من 'raw' و'raw-private' إلى ظهور NotSupportedError في المفاتيح الخاصة لآلية KEM. يُرجى العِلم أنّ 'MLKEM768-X25519' يتوافق فقط مع 'raw-public' و'raw-seed' و'jwk'، ولا يتوافق مع 'spki' أو 'pkcs8'.
  • 'raw-secret': وحدات بايت المفتاح السري المتماثل ('ChaCha20-Poly1305'، ومقبولة في 'AES-GCM' و'HKDF').

صدِّر المفتاح العام للمستلِم باستخدام 'raw-public' حتى يمكن تسلسل وحدات بايت المفتاح العام (على سبيل المثال، كـ Base64 عبر new Uint8Array(recipientPublicKeyBytes).toBase64() أو سداسي عشري) ومشاركتها مع المُرسِلين:

// Export the recipient's public key bytes
const recipientPublicKeyBytes = await crypto.subtle.exportKey(
  'raw-public',
  recipientKeyPair.publicKey,
);

// Import the recipient's public key on the sender (usage must be ['encapsulateKey'])
const importedPublicKey = await crypto.subtle.importKey(
  'raw-public',
  recipientPublicKeyBytes,
  'MLKEM768-X25519',
  true,
  ['encapsulateKey'],
);

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

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

3- تغليف مفتاح مشترَك وفك تغليفه

يستورد encapsulateKey() وdecapsulateKey() السر المشترك لآلية KEM المكوّن من 32 بايت (256 بت) إلى CryptoKey باستخدام الخوارزمية المحدّدة في sharedKeyAlgorithm (بتنسيق 'raw-secret'). مرِّر 'ChaCha20-Poly1305' أو 'AES-GCM' مباشرةً لإنشاء مفتاح تشفير AEAD:

// Sender encapsulates a 256-bit AEAD key and produces a ciphertext capsule
const { sharedKey: senderKey, ciphertext: encapsulatedCiphertext } =
  await crypto.subtle.encapsulateKey(
    'MLKEM768-X25519',
    importedPublicKey,
    'ChaCha20-Poly1305', // Or 'AES-GCM'
    false,
    ['encrypt', 'decrypt'],
  );

// Recipient decapsulates the ciphertext capsule to recover the identical AEAD key
const recipientKey = await crypto.subtle.decapsulateKey(
  'MLKEM768-X25519',
  recipientKeyPair.privateKey,
  encapsulatedCiphertext,
  'ChaCha20-Poly1305',
  false,
  ['encrypt', 'decrypt'],
);

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

لربط المفتاح المشترَك بسياق بروتوكول معيّن (info وsalt) أو اشتقاق مفاتيح فرعية متعدّدة من عملية تغليف واحدة، مرِّر 'HKDF' على أنّه sharedKeyAlgorithm مع استخدام ['deriveKey']، ثم استدعِ crypto.subtle.deriveKey():

const { sharedKey: hkdfBaseKey, ciphertext } = await crypto.subtle.encapsulateKey(
  'MLKEM768-X25519',
  importedPublicKey,
  'HKDF',
  false,
  ['deriveKey'],
);

const aeadKey = await crypto.subtle.deriveKey(
  {
    name: 'HKDF',
    hash: 'SHA-256',
    salt: new Uint8Array(32),
    info: new TextEncoder().encode('envelope-v1'),
  },
  hkdfBaseKey,
  { name: 'AES-GCM', length: 256 },
  false,
  ['encrypt', 'decrypt'],
);

4. تشفير الحمولات وفك تشفيرها ('ChaCha20-Poly1305' و'AES-GCM')

تشفير بيانات الحمولة باستخدام خوارزمية AEAD ‏ ('ChaCha20-Poly1305' أو 'AES-GCM') باستخدام متّجه إعداد (iv) عشوائي جديد ومشفَّر بحجم 12 بايت (96 بت) لكل عملية تشفير لا تُعِد استخدام زوج (iv, key) أبدًا. اختَر 'ChaCha20-Poly1305' للحصول على أداء ثابت في الوقت نفسه على جميع الأجهزة بدون تسريع أجهزة AES المخصّصة، أو 'AES-GCM' عند التشغيل التفاعلي مع عمليات نشر WebCrypto أو FIPS 140 الحالية.

  • ‫'ChaCha20-Poly1305': تستخدم مفتاحًا ثابتًا بحجم 256 بت (32 بايت) (تتطلّب السمة generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt']) سلسلة أو { name: 'ChaCha20-Poly1305' } بدون السمة length). يجب أن يكون طول iv‏ 12 بايت بالضبط، ويتم إلحاق علامة مصادقة Poly1305 بحجم 128 بت بالنص المشفّر.
  • 'AES-GCM': يتم تمرير { name: 'AES-GCM', length: 256 } عند استدعاء generateKey() أو deriveKey()، وiv بحجم 12 بايت عند استدعاء encrypt() وdecrypt().
  • additionalData (AAD): يتم ربط البيانات الوصفية غير المشفّرة (مثل معرّف السجلّ أو معرّف المستلِم أو إصدار البروتوكول) بعلامة المصادقة من خلال additionalData، وبالتالي يؤدي التلاعب بالنص المشفّر أو البيانات الوصفية إلى رفض decrypt() مع OperationError.
/**
 * Encrypts UTF-8 text with an AEAD CryptoKey ('ChaCha20-Poly1305' or 'AES-GCM').
 * @param {CryptoKey} key
 * @param {string} plaintext
 * @param {Uint8Array} [additionalData]
 */
export async function encryptMessage(key, plaintext, additionalData = new Uint8Array(0)) {
  // Always generate a fresh 12-byte (96-bit) IV per message
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encoded = new TextEncoder().encode(plaintext);

  const ciphertextBuffer = await crypto.subtle.encrypt(
    {
      name: key.algorithm.name,
      iv,
      additionalData,
    },
    key,
    encoded,
  );

  return { iv, ciphertext: new Uint8Array(ciphertextBuffer) };
}

/**
 * Decrypts and authenticates an AEAD payload. Throws OperationError if tampered.
 * @param {CryptoKey} key
 * @param {Uint8Array} iv
 * @param {Uint8Array} ciphertext
 * @param {Uint8Array} [additionalData]
 */
export async function decryptMessage(key, iv, ciphertext, additionalData = new Uint8Array(0)) {
  const plaintextBuffer = await crypto.subtle.decrypt(
    {
      name: key.algorithm.name,
      iv,
      additionalData,
    },
    key,
    ciphertext,
  );

  return new TextDecoder().decode(plaintextBuffer);
}

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

إذا كان هدف Baseline لا يتيح استخدام SubtleCrypto.supports() أو encapsulateKey() أو 'ChaCha20-Poly1305'، يمكنك رصد مدى إتاحة الاستخدام من خلال طريقة SubtleCrypto.supports() الثابتة والرجوع إلى اتفاقية مفتاح Diffie-Hellman الثابتة المؤقتة ('X25519' + 'HKDF' + 'AES-GCM' أو 'ECDH' مع 'P-256' عند استهداف المتصفّحات قبل إتاحة استخدام 'X25519'):

  • يجب استدعاء SubtleCrypto.supports() في الدالة الإنشائية SubtleCrypto، وليس في crypto.subtle: globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305') هي طريقة ثابتة متزامنة تعرض قيمة منطقية (crypto.subtle.supports هي undefined). يُرجى العِلم أنّ SubtleCrypto.supports() تتحقّق من صحة المَعلمات الخاصة بالعملية: تعمل سلاسل الخوارزميات البسيطة مع 'encapsulateKey' و'decapsulateKey' و'generateKey' و'importKey' و'exportKey' و'getPublicKey'، في حين أنّ فحص 'encrypt' أو 'decrypt' يتطلّب مباشرةً استخدام iv (على سبيل المثال، SubtleCrypto.supports('encrypt', { name: 'ChaCha20-Poly1305', iv: new Uint8Array(12) })).
  • تنسيق المفتاح في المسار الاحتياطي ('raw'): على الرغم من أنّ المتصفّحات التي تتوافق مع Modern WebCrypto تقبل أيضًا 'raw-public' على 'X25519' و'ECDH'، استخدِم 'raw' في المسارات الاحتياطية التي تستهدف المتصفّحات القديمة.
  • الخيار الاحتياطي الكلاسيكي ('X25519' + 'HKDF' + 'AES-GCM'): عندما لا يكون SubtleCrypto.supports() متاحًا أو يعرض false، يتم إنشاء مفتاحَي تشفير 'X25519' مؤقتَين للمُرسِل، وتصدير المفتاح العام المؤقت كوحدات بايت للكبسولة، واستخراج 256 بت من خلال deriveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256)، وتمرير وحدات البت هذه من خلال 'HKDF' ('SHA-256') لاستخراج مفتاح 'AES-GCM' بحجم 256 بت.
const KEM_PUBLIC_KEY_ALGORITHMS = {
  1184: 'ML-KEM-768',
  1216: 'MLKEM768-X25519',
  1568: 'ML-KEM-1024',
};

export function supportsPostQuantumEnvelope() {
  return (
    typeof globalThis.SubtleCrypto?.supports === 'function' &&
    SubtleCrypto.supports('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305')
  );
}

export async function generateRecipientKeys() {
  if (supportsPostQuantumEnvelope()) {
    const keyPair = await crypto.subtle.generateKey(
      'MLKEM768-X25519',
      false,
      ['encapsulateKey', 'decapsulateKey'],
    );
    const publicKeyBytes = new Uint8Array(
      await crypto.subtle.exportKey('raw-public', keyPair.publicKey),
    );
    return { suite: 'MLKEM768-X25519+ChaCha20-Poly1305', keyPair, publicKeyBytes };
  }

  // Classical fallback: X25519 key agreement + HKDF + AES-GCM
  const keyPair = await crypto.subtle.generateKey('X25519', false, ['deriveBits']);
  const publicKeyBytes = new Uint8Array(
    await crypto.subtle.exportKey('raw', keyPair.publicKey),
  );
  return { suite: 'X25519+HKDF+AES-GCM', keyPair, publicKeyBytes };
}

async function deriveFallbackAesKey(privateKey, peerPublicKey, infoBytes = new Uint8Array(0)) {
  const sharedSecret = await crypto.subtle.deriveBits(
    { name: 'X25519', public: peerPublicKey },
    privateKey,
    256,
  );
  const hkdfKey = await crypto.subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveKey']);
  return crypto.subtle.deriveKey(
    {
      name: 'HKDF',
      hash: 'SHA-256',
      salt: new Uint8Array(32),
      info: infoBytes,
    },
    hkdfKey,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt'],
  );
}

export async function encapsulateEnvelopeKey(
  recipientPublicKeyOrBytes,
  infoBytes = new Uint8Array(0),
) {
  const pubKey =
    recipientPublicKeyOrBytes instanceof CryptoKey
      ? recipientPublicKeyOrBytes
      : await (() => {
          const bytes =
            recipientPublicKeyOrBytes instanceof Uint8Array
              ? recipientPublicKeyOrBytes
              : new Uint8Array(recipientPublicKeyOrBytes);
          const kemName = KEM_PUBLIC_KEY_ALGORITHMS[bytes.byteLength];
          return kemName
            ? crypto.subtle.importKey('raw-public', bytes, kemName, true, ['encapsulateKey'])
            : crypto.subtle.importKey('raw', bytes, 'X25519', true, []);
        })();

  if (pubKey.algorithm.name !== 'X25519') {
    const { sharedKey, ciphertext } = await crypto.subtle.encapsulateKey(
      pubKey.algorithm.name,
      pubKey,
      'ChaCha20-Poly1305',
      false,
      ['encrypt', 'decrypt'],
    );
    return { sharedKey, capsuleBytes: new Uint8Array(ciphertext) };
  }

  // Classical fallback: generate ephemeral X25519 key pair and send ephemeral public key as capsule
  const ephemeral = await crypto.subtle.generateKey('X25519', false, ['deriveBits']);
  const capsuleBytes = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey));
  const sharedKey = await deriveFallbackAesKey(ephemeral.privateKey, pubKey, infoBytes);
  return { sharedKey, capsuleBytes };
}

export async function decapsulateEnvelopeKey(
  recipientPrivateKey,
  capsuleBytes,
  infoBytes = new Uint8Array(0),
) {
  if (recipientPrivateKey.algorithm.name !== 'X25519') {
    return crypto.subtle.decapsulateKey(
      recipientPrivateKey.algorithm.name,
      recipientPrivateKey,
      capsuleBytes,
      'ChaCha20-Poly1305',
      false,
      ['encrypt', 'decrypt'],
    );
  }

  const ephemeralPubKey = await crypto.subtle.importKey('raw', capsuleBytes, 'X25519', true, []);
  return deriveFallbackAesKey(recipientPrivateKey, ephemeralPubKey, infoBytes);
}