رمزگذاری داده‌ها در سمت کارخواه با WebCrypto

ویژگی‌های وب: رمزنگاری وب

از Web Cryptography API (crypto.subtle) برای ایجاد رمزهای مشترک در شبکه‌های غیرقابل‌اعتماد بااستفاده از «سازوکارهای کپسوله‌سازی کلید» (KEM) مقاوم دربرابر کوانتوم یا توافق کلید کلاسیک استفاده کنید و داده‌های حساس را در مرورگر با «رمزگذاری اصالت‌سنجی‌شده با داده‌های مرتبط» (AEAD) رمزگذاری کنید. برای امضا و تأیید پیام‌ها یا مصنوعات بدون رمزگذاری آن‌ها، به امضا و تأیید داده‌ها با WebCrypto مراجعه کنید.

جریان رمزگذاری پاکتی سرتاسر به دو مرحله تقسیم می‌شود:

  • راه‌اندازی یک‌باره گیرنده (مراحل ۱-۲): گیرنده یک جفت کلید تولید می‌کند، CryptoKeyPair را در IndexedDB برای جلسات آینده حفظ می‌کند، و کلید عمومی ('raw-public') را برای هم‌رسانی با فرستندگان صادر می‌کند.
  • رمزگذاری برای هر پیام (مراحل ۳–۴): برای هر بار داده، فرستنده کلید عمومی گیرنده را وارد می‌کند، encapsulateKey() را فراخوانی می‌کند تا کلید مشترک یک‌باره و کپسول متن رمزی جدیدی تولید کند، بار داده را با IV جدید ۱۲ بایتی رمزگذاری می‌کند، و { capsule, iv, ciphertext } را به گیرنده ارسال می‌کند.

۱. الگوریتم KEM را انتخاب کنید و جفت کلید گیرنده را تولید کنید

برخلاف توافق کلید دیفی-هلمن (deriveKey()) که در آن هر دو طرف کلیدهای خود را ترکیب می‌کنند، «سازوکار کپسوله‌سازی کلید» (KEM) از کلید عمومی گیرنده برای تولید یک رمز مشترک تصادفی و یک کپسول متن رمزی در یک فراخوانی encapsulateKey() استفاده می‌کند. گیرنده همان کلید مشترک را بااستفاده از decapsulateKey() از کپسول متن رمزی بازیابی می‌کند.

به‌طور پیش‌فرض از 'MLKEM768-X25519' استفاده کنید: این الگوریتم ML-KEM پساکوانتومی (استانداردشده در NIST FIPS 203) را با 'X25519' کلاسیک ترکیب می‌کند تا رمز مشترک تا زمانی که یکی از این دو الگوریتم معتبر باشد، امن باقی بماند. وقتی خط‌مشی به خروجی خالص FIPS 203 بدون مؤلفه ترکیبی نیاز دارد، 'ML-KEM-768' مستقل را انتخاب کنید، یا وقتی خط‌مشی به رعایت NIST Category 5 (CNSA 2.0) نیاز دارد، 'ML-KEM-1024' را انتخاب کنید.

نام الگوریتم سطح امنیتی و کاربرد کلید عمومی ('raw-public') دانه خصوصی ('raw-seed') اندازه متن رمزی
'MLKEM768-X25519' ترکیبی پسا کوانتومی ('ML-KEM-768') + کلاسیک ('X25519')؛ پیش‌فرض توصیه‌شده ‫۱۲۱۶ بایت ‫۳۲ بایت ‫۱۱۲۰ بایت
'ML-KEM-768' NIST Category 3 (~192-bit security); standalone post-quantum KEM ‫۱۱۸۴ بایت ‫۶۴ بایت ‫۱۰۸۸ بایت
'ML-KEM-1024' NIST Category 5 (~256-bit security); highest security margin 1568 bytes ‫۶۴ بایت 1568 bytes
  • ‫'MLKEM768-X25519'،‏ 'ML-KEM-768'، یا 'ML-KEM-1024' را به‌عنوان نام الگوریتم ارسال کنید (نام‌های مستعار مانند 'X-Wing' یا 'X25519MLKEM768'، و همچنین 'ML-KEM-512'،‏ NotSupportedError را برمی‌گردانند).
  • هنگام تولید جفت کلید KEM، extractable: false و ['encapsulateKey', 'decapsulateKey'] (یا ['encapsulateBits', 'decapsulateBits']) را ارسال کنید. اشیاء غیرقابل‌استخراج CryptoKey و CryptoKeyPair را می‌توان مستقیماً در IndexedDB ازطریق شبیه‌سازی ساختاریافته (objectStore.put(recipientKeyPair, 'recipient-kem-key')) بدون صادر کردن بایت‌های کلید خصوصی در جلسات حفظ کرد:
const recipientKeyPair = await crypto.subtle.generateKey(
  'MLKEM768-X25519',
  false,
  ['encapsulateKey', 'decapsulateKey'],
);

۲. صادر و وارد کردن کلیدها

الگوریتم‌های WebCrypto مدرن ('MLKEM768-X25519'،‏ 'ML-KEM-768'،‏ 'ML-KEM-1024'، و 'ChaCha20-Poly1305') قالب قدیمی 'raw' را در importKey() و exportKey() با NotSupportedError رد می‌کنند. همیشه قالب کلید صریح را مشخص کنید:

  • 'raw-public': بایت‌های کلید عمومی (1216، 1184، یا 1568 بایت). هنگام وارد کردن کلید عمومی KEM،‏ ['encapsulateKey'] (یا ['encapsulateBits']) را ارسال کنید.
  • 'raw-seed': بایت‌های دانه کلید خصوصی (32 بایت برای 'MLKEM768-X25519'؛ 64 بایت برای 'ML-KEM-768' و 'ML-KEM-1024'). هنگام وارد کردن کلید خصوصی KEM،‏ ['decapsulateKey'] (یا ['decapsulateBits']) را ارسال کنید (ارسال ['encapsulateKey', 'decapsulateKey'] به importKey() یا getPublicKey() باعث ایجاد SyntaxError می‌شود). هر دو 'raw' و 'raw-private' در کلیدهای خصوصی KEM باعث ایجاد NotSupportedError می‌شوند. توجه داشته باشید که 'MLKEM768-X25519' فقط از 'raw-public'، 'raw-seed'، و 'jwk' پشتیبانی می‌کند—از 'spki' یا 'pkcs8' پشتیبانی نمی‌کند.
  • 'raw-secret': بایت‌های کلید مخفی متقارن ('ChaCha20-Poly1305'، و پذیرفته‌شده در 'AES-GCM' و 'HKDF').

کلید عمومی گیرنده را با 'raw-public' صادر کنید تا بایت‌های کلید عمومی بتوانند سریال‌سازی شوند (برای مثال، به‌عنوان Base64 ازطریق new Uint8Array(recipientPublicKeyBytes).toBase64() یا hex) و با فرستندگان هم‌رسانی شوند:

// 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'],
);

۳. کپسوله‌سازی و کپسوله‌زدایی کلید مشترک

‫encapsulateKey() و decapsulateKey() راز مشترک KEM‏ ۳۲ بایتی (۲۵۶ بیتی) را بااستفاده از الگوریتم مشخص‌شده در sharedKeyAlgorithm (با قالب 'raw-secret') به CryptoKey وارد می‌کنند. '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'],
);

۴. رمزگذاری و رمزگشایی بار داده ('ChaCha20-Poly1305' و 'AES-GCM')

داده‌های بار مفید را با الگوریتم AEAD ('ChaCha20-Poly1305' یا 'AES-GCM') بااستفاده از بردار اولیه ۱۲ بایتی (۹۶ بیتی) (iv) تصادفی رمزنگاری‌شده جدید برای هر عملیات رمزگذاری رمزگذاری کنید. هرگز از جفت (iv, key) دوباره استفاده نکنید. 'ChaCha20-Poly1305' را برای عملکرد ثابت در زمان ثابت در دستگاه‌های بدون شتاب سخت‌افزاری AES اختصاصی انتخاب کنید، یا 'AES-GCM' را هنگام تعامل با WebCrypto موجود یا استقرار FIPS 140 انتخاب کنید.

  • ‫'ChaCha20-Poly1305': از کلید ثابت ۲۵۶ بیتی (۳۲ بایتی) استفاده می‌کند (generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt']) رشته یا { name: 'ChaCha20-Poly1305' } را با ویژگی length می‌گیرد). ‫iv باید دقیقاً ۱۲ بایت باشد و برچسب اصالت‌سنجی Poly1305‏ ۱۲۸ بیتی به متن رمزگذاری‌شده اضافه می‌شود.
  • 'AES-GCM': هنگام فراخوانی generateKey() یا deriveKey()، { name: 'AES-GCM', length: 256 } را ارسال کنید و هنگام فراخوانی encrypt() و decrypt()، iv ۱۲ بایتی را ارسال کنید.
  • 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' پشتیبانی نمی‌کند، پشتیبانی را بااستفاده از روش static SubtleCrypto.supports() شناسایی کنید و به توافق کلید دیفی-هلمن زودگذر-ثابت ('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' زودگذر برای فرستنده تولید کنید، کلید عمومی زودگذر را به‌عنوان بایت‌های کپسول صادر کنید، ۲۵۶ بیت را ازطریق deriveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256) مشتق کنید، و آن بیت‌ها را ازطریق 'HKDF' ('SHA-256') عبور دهید تا یک کلید 'AES-GCM' ۲۵۶ بیتی مشتق شود.
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);
}