Utilizza l'API Web Cryptography (crypto.subtle) per stabilire secret condivisi su reti non attendibili utilizzando meccanismi di incapsulamento delle chiavi (KEM) resistenti ai computer quantistici o l'accordo di chiave classico e cripta i dati sensibili nel browser con la crittografia autenticata con dati associati (AEAD). Per firmare e verificare messaggi o artefatti senza criptarli, consulta Firma e verifica i dati con WebCrypto.
Un flusso di crittografia envelope end-to-end si divide in due fasi:
- Configurazione una tantum del destinatario (passaggi 1-2): il destinatario genera una coppia di chiavi, conserva la
CryptoKeyPairin IndexedDB per le sessioni future ed esporta la chiave pubblica ('raw-public') da condividere con i mittenti. - Crittografia per messaggio (passaggi 3-4): per ogni payload, il mittente importa la chiave pubblica del destinatario, chiama
encapsulateKey()per generare una nuova chiave condivisa monouso e una capsula di testo cifrato, cripta il payload con un nuovo IV di 12 byte e invia{ capsule, iv, ciphertext }al destinatario.
1. Scegli un algoritmo KEM e genera una coppia di chiavi del destinatario
A differenza dell'accordo di chiave Diffie-Hellman (deriveKey()), in cui entrambe le parti combinano le proprie chiavi, un Key Encapsulation Mechanism (KEM) utilizza la chiave pubblica del destinatario per generare sia un secret condiviso casuale sia una capsula di testo cifrato in una singola chiamata encapsulateKey(). Il destinatario recupera la stessa chiave condivisa dalla capsula del testo cifrato utilizzando decapsulateKey().
Utilizza 'MLKEM768-X25519' per impostazione predefinita: combina ML-KEM post-quantistico (standardizzato in NIST FIPS 203) con 'X25519' classico, in modo che il secret condiviso rimanga sicuro finché uno dei due algoritmi è valido. Scegli l'algoritmo autonomo 'ML-KEM-768' quando una policy richiede un output FIPS 203 puro senza un componente ibrido o 'ML-KEM-1024' quando una policy richiede la conformità con NIST Category 5 (CNSA 2.0).
| Nome algoritmo | Livello di sicurezza e caso d'uso | Chiave pubblica ('raw-public') |
Seed privato ('raw-seed') |
Dimensioni del testo crittografato |
|---|---|---|---|---|
'MLKEM768-X25519' |
Ibrido post-quantistico ('ML-KEM-768') + classico ('X25519'); impostazione predefinita consigliata |
1216 byte | 32 byte | 1120 byte |
'ML-KEM-768' |
NIST Categoria 3 (sicurezza ~192 bit); KEM post-quantistico autonomo | 1184 byte | 64 byte | 1088 byte |
'ML-KEM-1024' |
NIST Categoria 5 (sicurezza ~256 bit); margine di sicurezza più elevato | 1568 byte | 64 byte | 1568 byte |
- Passa
'MLKEM768-X25519','ML-KEM-768'o'ML-KEM-1024'come nome dell'algoritmo (alias come'X-Wing'o'X25519MLKEM768', nonché'ML-KEM-512', generanoNotSupportedError). - Passa
extractable: falsee['encapsulateKey', 'decapsulateKey'](o['encapsulateBits', 'decapsulateBits']) durante la generazione di una coppia di chiavi KEM. Gli oggettiCryptoKeyeCryptoKeyPairnon estraibili possono essere mantenuti tra le sessioni direttamente in IndexedDB tramite la clonazione strutturata (objectStore.put(recipientKeyPair, 'recipient-kem-key')) senza mai esportare i byte della chiave privata:
const recipientKeyPair = await crypto.subtle.generateKey(
'MLKEM768-X25519',
false,
['encapsulateKey', 'decapsulateKey'],
);
2. Esporta e importa le chiavi
I moderni algoritmi WebCrypto ('MLKEM768-X25519', 'ML-KEM-768', 'ML-KEM-1024' e 'ChaCha20-Poly1305') rifiutano il formato 'raw' precedente in importKey() e exportKey() con un NotSupportedError. Specifica sempre il formato della chiave esplicita:
'raw-public': byte della chiave pubblica (1216,1184o1568byte). Passa['encapsulateKey'](o['encapsulateBits']) quando importi una chiave pubblica KEM.'raw-seed': byte di seed della chiave privata (32byte per'MLKEM768-X25519';64byte per'ML-KEM-768'e'ML-KEM-1024'). Passa['decapsulateKey'](o['decapsulateBits']) quando importi una chiave privata KEM (il passaggio di['encapsulateKey', 'decapsulateKey']aimportKey()ogetPublicKey()generaSyntaxError). Sia'raw'che'raw-private'generanoNotSupportedErrorsulle chiavi private KEM. Tieni presente che'MLKEM768-X25519'supporta solo'raw-public','raw-seed'e'jwk', non supporta'spki'o'pkcs8'.'raw-secret': byte della chiave segreta simmetrica ('ChaCha20-Poly1305'e accettati su'AES-GCM'e'HKDF').
Esporta la chiave pubblica del destinatario con 'raw-public' in modo che i byte della chiave pubblica possano essere serializzati (ad esempio, come Base64 tramite new Uint8Array(recipientPublicKeyBytes).toBase64() o esadecimale) e condivisi con i mittenti:
// 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'],
);
Se hai solo una CryptoKey privata (anche con extractable: false) e hai bisogno della CryptoKey pubblica corrispondente, chiama crypto.subtle.getPublicKey():
const publicKey = await crypto.subtle.getPublicKey(
recipientKeyPair.privateKey,
['encapsulateKey'],
);
3. Incapsula e decapsula una chiave condivisa
encapsulateKey() e decapsulateKey() importano il secret condiviso KEM a 32 byte (256 bit) in un CryptoKey utilizzando l'algoritmo specificato in sharedKeyAlgorithm (con formato 'raw-secret'). Passa 'ChaCha20-Poly1305' o 'AES-GCM' direttamente per produrre una chiave di crittografia 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'],
);
Separazione dei domini con 'HKDF'
Per associare la chiave condivisa a un contesto di protocollo specifico (info e salt) o derivare più sottochiavi da un singolo incapsulamento, passa 'HKDF' come sharedKeyAlgorithm con utilizzo ['deriveKey'], quindi chiama 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. Cripta e decripta i payload ('ChaCha20-Poly1305' e 'AES-GCM')
Cripta i dati del payload con un algoritmo AEAD ('ChaCha20-Poly1305' o 'AES-GCM') utilizzando un nuovo vettore di inizializzazione da 12 byte (96 bit) (iv) casuale dal punto di vista crittografico per ogni operazione di crittografia. Non riutilizzare mai una coppia (iv, key). Scegli 'ChaCha20-Poly1305' per prestazioni costanti nel tempo su tutti i dispositivi senza accelerazione hardware AES dedicata o 'AES-GCM' per l'interoperabilità con i deployment WebCrypto o FIPS 140 esistenti.
'ChaCha20-Poly1305': utilizza una chiave fissa a 256 bit (32 byte) (generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt'])accetta una stringa o{ name: 'ChaCha20-Poly1305' }senza proprietàlength). Ilivdeve essere esattamente di 12 byte e il tag di autenticazione Poly1305 a 128 bit viene aggiunto al testo cifrato.'AES-GCM': passa{ name: 'AES-GCM', length: 256 }quando chiamigenerateKey()oderiveKey()e univdi 12 byte quando chiamiencrypt()edecrypt().additionalData(AAD): associa i metadati non criptati (come un ID record, un ID destinatario o una versione del protocollo) al tag di autenticazione tramiteadditionalDatain modo che la manomissione del testo cifrato o dei metadati causi il rifiuto didecrypt()conOperationError.
/**
* 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);
}
Strategie di riserva
Se il tuo target Baseline non supporta ancora SubtleCrypto.supports(), encapsulateKey() o 'ChaCha20-Poly1305', rileva il supporto utilizzando il metodo static SubtleCrypto.supports() e utilizza l'accordo di chiave Diffie-Hellman effimero-statico ('X25519' + 'HKDF' + 'AES-GCM' o 'ECDH' con 'P-256' quando scegli come target browser precedenti al supporto di 'X25519'):
- Chiama
SubtleCrypto.supports()sul costruttoreSubtleCrypto, non sucrypto.subtle:globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305')è un metodo statico sincrono che restituisce un valore booleano (crypto.subtle.supportsèundefined). Tieni presente cheSubtleCrypto.supports()convalida i parametri specifici dell'operazione: le stringhe di algoritmo bare funzionano per'encapsulateKey','decapsulateKey','generateKey','importKey','exportKey'e'getPublicKey', mentre il probing di'encrypt'o'decrypt'richiede direttamente univ(ad esempio,SubtleCrypto.supports('encrypt', { name: 'ChaCha20-Poly1305', iv: new Uint8Array(12) })). - Formato della chiave nel fallback (
'raw'): anche se i browser che supportano Modern WebCrypto accettano anche'raw-public'su'X25519'e'ECDH', utilizza'raw'nei percorsi di fallback che hanno come target i browser meno recenti. - Fallback classico (
'X25519'+'HKDF'+'AES-GCM'): quandoSubtleCrypto.supports()non è disponibile o restituiscefalse, genera una coppia di chiavi'X25519'temporanea per il mittente, esporta la chiave pubblica temporanea come byte della capsula, ricava 256 bit tramitederiveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256)e passa questi bit tramite'HKDF'('SHA-256') per ricavare una chiave'AES-GCM'a 256 bit.
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);
}