משתמשים ב-Web Cryptography API (crypto.subtle) כדי ליצור סודות משותפים ברשתות לא מהימנות באמצעות מנגנוני Key Encapsulation Mechanisms (KEM) שעמידים בפני מחשוב קוונטי או הסכמה קלאסית על מפתחות, ומצפינים נתונים רגישים בדפדפן באמצעות הצפנה מאומתת עם מידע נלווה (AEAD). כדי לחתום על הודעות או פריטי מידע ולאמת אותם בלי להצפין אותם, קראו את המאמר חתימה על נתונים ואימות שלהם באמצעות WebCrypto.
תהליך של הצפנת מעטפה מקצה לקצה מחולק לשני שלבים:
- הגדרה חד-פעמית של הנמען (שלבים 1-2): הנמען יוצר זוג מפתחות, שומר את
CryptoKeyPairב-IndexedDB לסשנים עתידיים ומייצא את המפתח הציבורי ('raw-public') כדי לשתף אותו עם השולחים. - הצפנה לכל הודעה (שלבים 3-4): בכל מטען ייעודי (payload), השולח מייבא את המפתח הציבורי של הנמען, קורא ל-
encapsulateKey()כדי ליצור מפתח משותף חד-פעמי חדש וקפסולת מידע מוצפן (ciphertext), מצפין את המטען הייעודי באמצעות וקטור אתחול (IV) חדש של 12 בייטים ושולח את{ capsule, iv, ciphertext }לנמען.
1. בוחרים אלגוריתם KEM ויוצרים זוג מפתחות לנמען
בניגוד להסכם המפתחות של Diffie-Hellman (deriveKey()), שבו שני הצדדים משלבים את המפתחות שלהם, מנגנון Key Encapsulation Mechanism (KEM) משתמש במפתח הציבורי של הנמען כדי ליצור גם סוד משותף אקראי וגם קפסולת מידע מוצפן (ciphertext) בקריאת encapsulateKey() אחת. הנמען משחזר את אותו מפתח משותף מהקפסולה של המידע המוצפן באמצעות decapsulateKey().
כדאי להשתמש באפשרות 'MLKEM768-X25519' כברירת מחדל: היא משלבת את ML-KEM הפוסט-קוונטי (תקן NIST FIPS 203) עם 'X25519' הקלאסי, כך שהסוד המשותף נשאר מאובטח כל עוד אחד מהאלגוריתמים פועל. בחרו באפשרות העצמאית 'ML-KEM-768' אם המדיניות דורשת פלט FIPS 203 טהור בלי רכיב היברידי, או באפשרות 'ML-KEM-1024' אם המדיניות דורשת תאימות ל-NIST קטגוריה 5 (CNSA 2.0).
| שם האלגוריתם | רמת אבטחה ותרחיש לדוגמה | מפתח ציבורי ('raw-public') |
גרעין פרטי ('raw-seed') |
גודל המידע המוצפן (ciphertext) |
|---|---|---|---|---|
'MLKEM768-X25519' |
היברידי פוסט-קוונטי ('ML-KEM-768') + קלאסי ('X25519'); ברירת המחדל המומלצת |
1,216 בייטים | 32 בייטים | 1,120 בייטים |
'ML-KEM-768' |
קטגוריה 3 של NIST (אבטחה של כ-192 ביט); מנגנון KEM פוסט-קוונטי עצמאי | 1,184 בייטים | 64 בייטים | 1,088 בייטים |
'ML-KEM-1024' |
NIST קטגוריה 5 (אבטחה של כ-256 ביט); מרווח האבטחה הגבוה ביותר | 1,568 בייטים | 64 בייטים | 1,568 בייטים |
- מעבירים את
'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')
מצפינים את נתוני המטען הייעודי (payload) באמצעות אלגוריתם 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 ביט מצורף למידע המוצפן (ciphertext). -
'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()ב-constructor של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) })). - פורמט המפתח ב-fallback (
'raw'): דפדפנים שתומכים ב-Modern WebCrypto מקבלים גם את'raw-public'ב-'X25519'וב-'ECDH', אבל כדאי להשתמש ב-'raw'בנתיבי fallback שמטרגטים דפדפנים ישנים יותר. - חלופה קלאסית (
'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);
}