משתמשים ב-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);
}