امضا و درستی‌سنجی کردن داده‌ها با WebCrypto

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

از Web Cryptography API (crypto.subtle) برای امضا و تأیید پیام‌ها، توکن‌ها، یا مصنوعات در مرورگر بااستفاده از کلیدهای نامتقارن مقاوم دربرابر کوانتوم (ML-DSA) یا کلاسیک ('Ed25519'، 'ECDSA') استفاده کنید. امضاهای دیجیتال تمامیت داده‌ها و اصالت امضاکننده را بدون نیاز به کلیدهای محرمانه مشترک تضمین می‌کنند. برای ایجاد رمزهای مشترک و رمزگذاری محموله‌های محرمانه، به رمزگذاری داده‌ها در سمت کارخواه با WebCrypto مراجعه کنید.

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

‫ML-DSA (الگوریتم امضای دیجیتال مبتنی بر شبکه ماژول، استانداردشده در NIST FIPS 204) امضاهای دیجیتال مبتنی بر شبکه پساکوانتومی را ارائه می‌دهد که دربرابر حملات رایانه‌های کلاسیک و کوانتومی مقاوم هستند.

به‌طور پیش‌فرض از 'ML-DSA-65' برای امضای عمومی استفاده کنید. اگر به کلیدهای عمومی و امضاهای کوچک‌تر در کانال‌های با پهنای باند محدود نیاز دارید و امنیت ۱۲۸ بیتی کافی است، 'ML-DSA-44' را انتخاب کنید، یا اگر خط‌مشی امنیتی به رعایت «دسته ۵ NIST»‏ (CNSA 2.0) نیاز دارد، 'ML-DSA-87' را انتخاب کنید.

نام الگوریتم سطح امنیتی و کاربرد کلید عمومی ('raw-public') اندازه امضا
'ML-DSA-44' NIST Category 2 (امنیت تقریباً ۱۲۸ بیتی)؛ کوچک‌ترین کلیدها و امضاها ‫۱۳۱۲ بایت ‫۲۴۲۰ بایت
'ML-DSA-65' NIST Category 3 (امنیت تقریباً ۱۹۲ بیتی)؛ پیش‌فرض توصیه‌شده ‫۱۹۵۲ بایت 3309 bytes
'ML-DSA-87' NIST Category 5 (~256-bit security); highest security margin ‫۲۵۹۲ بایت 4627 bytes
  • ‫'ML-DSA-44'، 'ML-DSA-65'، یا 'ML-DSA-87' را به‌عنوان نام الگوریتم ارسال کنید (نام‌های پیش‌از استانداردسازی مانند 'Dilithium' یا 'CRYSTALS-Dilithium' باعث ایجاد NotSupportedError می‌شوند).
  • نه ML-DSA و نه 'Ed25519' پارامتر hash را نمی‌پذیرند.
  • هنگام تولید جفت کلید با generateKey()، ['sign', 'verify'] را ارسال کنید:
const signerKeyPair = await crypto.subtle.generateKey(
  'ML-DSA-65',
  false,
  ['sign', 'verify'],
);

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

‫'ML-DSA-44'،‏ 'ML-DSA-65'، و 'ML-DSA-87' قالب قدیمی 'raw' را در importKey() و exportKey() با NotSupportedError رد می‌کنند. همیشه کلیدها را بااستفاده از قالب صریح صادر یا وارد کنید:

  • 'raw-public': بایت‌های کلید عمومی (1312، 1952، یا 2592 بایت). هنگام وارد کردن کلید عمومی، ['verify'] را به‌عنوان کاربرد کلید ارسال کنید.
  • 'raw-seed': دانه کلید خصوصی ۳۲ بایتی برای هر سه الگوریتم ML-DSA. Both 'raw' and 'raw-private' throw NotSupportedError on ML-DSA—store or transmit private keys using 'raw-seed', 'pkcs8' (54 bytes), or 'jwk' (kty: 'AKP'). Pass ['sign'] as the key usage when importing a private key (passing ['sign', 'verify'] to importKey() throws 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'],
);

۳. امضا و درستی‌سنجی بار داده‌ها

با کلید خصوصی 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,
);
  • محدودیت ۲۵۵ بایتی: context از 0 تا 255 بایت را می‌پذیرد (حذف context معادل ارسال new Uint8Array(0) خالی است). ارسال بیش‌از 255 بایت با OperationError رد می‌شود.
  • مطابقت دقیق لازم است: crypto.subtle.verify() فقط زمانی true را برمی‌گرداند که درستی‌سنج دقیقاً همان بایت‌های context را که درطول crypto.subtle.sign() استفاده شده است ارسال کند.

راهبردهای جایگزین

اگر هدف Baseline شما هنوز از SubtleCrypto.supports() یا ML-DSA پشتیبانی نمی‌کند، پشتیبانی را بااستفاده از روش static SubtleCrypto.supports() شناسایی کنید و به 'Ed25519' کلاسیک (یا 'ECDSA' با 'P-256' و 'SHA-256' هنگام هدف‌یابی مرورگرهای قبل‌از پشتیبانی 'Ed25519') برگردید:

  • Call SubtleCrypto.supports() on the SubtleCrypto constructor, not on crypto.subtle: globalThis.SubtleCrypto?.supports?.('sign', 'ML-DSA-65') is a synchronous static method returning a boolean (crypto.subtle.supports is undefined). Probe 'sign', 'verify', 'generateKey', 'importKey', 'exportKey', or 'getPublicKey' (or pass a ContextParams dictionary such as 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);
}