חתימה על נתונים ואימות שלהם באמצעות WebCrypto

תכונות באינטרנט: קריפטוגרפיה באינטרנט

משתמשים ב-Web Cryptography API‏ (crypto.subtle) כדי לחתום על הודעות, טוקנים או פריטי מידע ולאמת אותם בדפדפן באמצעות מפתחות אסימטריים קוונטיים (ML-DSA) או קלאסיים ('Ed25519',‏ 'ECDSA'). חתימות דיגיטליות מבטיחות את תקינות הנתונים ואת האותנטיות של החותם, בלי צורך במפתחות סודיים משותפים. כדי ליצור סודות משותפים ולהצפין מטען ייעודי (payload) סודי, אפשר לעיין במאמר הצפנת נתונים בצד הלקוח באמצעות WebCrypto.

1. בחירת אלגוריתם ויצירת זוג מפתחות

‫ML-DSA (אלגוריתם חתימה דיגיטלית מבוסס רשת מודולים, תקן NIST FIPS 204) מספק חתימות דיגיטליות מבוססות רשת פוסט-קוונטיות שעמידות בפני התקפות של מחשבים קלאסיים וקוונטיים.

כברירת מחדל, משתמשים ב-'ML-DSA-65' לחתימה למטרות כלליות. בוחרים באפשרות 'ML-DSA-44' כשצריך מפתחות ציבוריים וחתימות קטנים יותר בערוצים עם רוחב פס מוגבל, ואבטחה של 128 ביט מספיקה. בוחרים באפשרות 'ML-DSA-87' כשמדיניות האבטחה דורשת תאימות ל-NIST קטגוריה 5 (CNSA 2.0).

שם האלגוריתם רמת אבטחה ותרחיש לדוגמה מפתח ציבורי ('raw-public') גודל החתימה
'ML-DSA-44' NIST קטגוריה 2 (אבטחה של כ-128 ביט); המפתחות והחתימות הקטנים ביותר ‫1,312 בייטים ‫2,420 בייטים
'ML-DSA-65' NIST קטגוריה 3 (אבטחה של כ-192 ביט); ברירת המחדל המומלצת ‫1,952 בייטים ‫3,309 בייטים
'ML-DSA-87' NIST קטגוריה 5 (אבטחה של כ-256 ביט); מרווח האבטחה הגבוה ביותר ‫2,592 בייטים ‫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. חתימה ואימות של מטענים ייעודיים (payloads)

מפעילים את 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().

אסטרטגיות חלופיות

אם יעד ה-Baseline שלכם עדיין לא תומך ב-SubtleCrypto.supports() או ב-ML-DSA, אתם יכולים לזהות את התמיכה באמצעות השיטה הסטטית SubtleCrypto.supports() ולחזור ל-'Ed25519' הקלאסי (או ל-'ECDSA' עם 'P-256' ו-'SHA-256' כשמטרגטים דפדפנים לפני התמיכה ב-'Ed25519'):

  • צריך להפעיל את SubtleCrypto.supports() ב-constructor של 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 })).
  • פורמט המפתח ב-fallback ('raw'): דפדפנים שתומכים ב-Modern WebCrypto מקבלים גם את 'raw-public' ב-'Ed25519' וב-'ECDSA', אבל כדאי להשתמש ב-'raw' בנתיבי fallback שמטרגטים דפדפנים ישנים יותר.
  • הפרדת דומיינים ב-fallback: אלגוריתמים קלאסיים של WebCrypto ('Ed25519' ו-'ECDSA') לא קוראים מאפיין context ממילון האלגוריתמים (WebIDL מתעלם בשקט ממאפייני מילון לא ידועים ב-Algorithm וב-EcdsaParams). מוסיפים כותרת context עם קידומת אורך למטען הייעודי (payload) בנתיב ה-fallback כדי שהפרדת הדומיינים תפעל באופן עקבי בשתי החבילות.
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);
}