Firma e verifica i dati con WebCrypto

Funzionalità web: Web Cryptography

Utilizza l'API Web Cryptography (crypto.subtle) per firmare e verificare messaggi, token o artefatti nel browser utilizzando chiavi asimmetriche resistenti ai computer quantistici (ML-DSA) o classiche ('Ed25519', 'ECDSA'). Le firme digitali garantiscono l'integrità dei dati e l'autenticità del firmatario senza richiedere chiavi segrete condivise. Per stabilire secret condivisi e criptare payload riservati, consulta Cripta i dati lato client con WebCrypto.

1. Scegli un algoritmo e genera una coppia di chiavi

ML-DSA (Module-Lattice-Based Digital Signature Algorithm, standardizzato in NIST FIPS 204) fornisce firme digitali basate su reticolo post-quantistiche resistenti agli attacchi di computer classici e quantistici.

Utilizza 'ML-DSA-65' per impostazione predefinita per la firma per uso generico. Scegli 'ML-DSA-44' quando hai bisogno di chiavi pubbliche e firme più piccole su canali con larghezza di banda limitata e la sicurezza a 128 bit è sufficiente, oppure 'ML-DSA-87' quando una policy di sicurezza richiede la conformità con la categoria 5 del NIST (CNSA 2.0).

Nome algoritmo Livello di sicurezza e caso d'uso Chiave pubblica ('raw-public') Dimensioni della firma
'ML-DSA-44' NIST Categoria 2 (sicurezza ~128 bit); chiavi e firme più piccole 1312 byte 2420 byte
'ML-DSA-65' Categoria 3 NIST (sicurezza ~192 bit); impostazione predefinita consigliata 1952 byte 3309 byte
'ML-DSA-87' NIST Categoria 5 (sicurezza ~256 bit); margine di sicurezza più elevato 2592 byte 4627 byte
  • Passa 'ML-DSA-44', 'ML-DSA-65' o 'ML-DSA-87' come nome dell'algoritmo (i nomi pre-standardizzazione come 'Dilithium' o 'CRYSTALS-Dilithium' generano NotSupportedError).
  • Né ML-DSA né 'Ed25519' accettano un parametro hash.
  • Passa ['sign', 'verify'] quando generi una coppia di chiavi con generateKey():
const signerKeyPair = await crypto.subtle.generateKey(
  'ML-DSA-65',
  false,
  ['sign', 'verify'],
);

2. Esporta e importa le chiavi

'ML-DSA-44', 'ML-DSA-65' e 'ML-DSA-87' rifiutano il formato 'raw' legacy in importKey() e exportKey() con un NotSupportedError. Esporta o importa sempre le chiavi utilizzando un formato esplicito:

  • 'raw-public': byte della chiave pubblica (1312, 1952 o 2592 byte). Passa ['verify'] come utilizzo della chiave durante l'importazione di una chiave pubblica.
  • 'raw-seed': seed della chiave privata da 32 byte per tutti e tre gli algoritmi ML-DSA. Sia 'raw' che 'raw-private' generano NotSupportedError su ML-DSA: archivia o trasmetti le chiavi private utilizzando 'raw-seed', 'pkcs8' (54 byte) o 'jwk' (kty: 'AKP'). Passa ['sign'] come utilizzo della chiave durante l'importazione di una chiave privata (il passaggio di ['sign', 'verify'] a importKey() genera SyntaxError).
  • 'spki', 'pkcs8' e 'jwk': supportati per tutti e tre gli algoritmi ML-DSA.

Esporta sempre la chiave pubblica di verifica con 'raw-public' in modo che i byte della chiave pubblica possano essere serializzati o condivisi con i verificatori:

// 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'],
);

Se hai solo una CryptoKey di firma privata (anche con extractable: false) e hai bisogno della CryptoKey pubblica corrispondente, chiama crypto.subtle.getPublicKey() con ['verify']:

const publicKey = await crypto.subtle.getPublicKey(
  signerKeyPair.privateKey,
  ['verify'],
);

3. Firma e verifica i payload

Chiama crypto.subtle.sign() con la chiave privata per generare una firma e crypto.subtle.verify() con la chiave pubblica per verificarla:

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,
);

Separazione dei domini con ContextParams

Per evitare che una firma creata per uno scopo venga riprodotta in un'altra parte dell'applicazione o del protocollo che condivide la stessa coppia di chiavi, associa la firma a un context di separazione dei domini.

Anziché una stringa con il nome dell'algoritmo, passa un dizionario ContextParams ({ name, context }) a sign() e verify(), dove context è un BufferSource (Uint8Array o 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,
);
  • Limite di 255 byte: context accetta da 0 a 255 byte (omettere context equivale a passare un new Uint8Array(0) vuoto). Se passi più di 255 byte, l'operazione viene rifiutata con OperationError.
  • Corrispondenza esatta richiesta: crypto.subtle.verify() restituisce true solo quando il verificatore passa esattamente gli stessi byte context utilizzati durante crypto.subtle.sign().

Strategie di riserva

Se il tuo target Baseline non supporta ancora SubtleCrypto.supports() o ML-DSA, rileva il supporto utilizzando il metodo statico SubtleCrypto.supports() ed esegui il fallback al classico 'Ed25519' (o 'ECDSA' con 'P-256' e 'SHA-256' quando scegli come target i browser precedenti al supporto di 'Ed25519'):

  • Chiama SubtleCrypto.supports() sul costruttore SubtleCrypto, non su crypto.subtle: globalThis.SubtleCrypto?.supports?.('sign', 'ML-DSA-65') è un metodo statico sincrono che restituisce un valore booleano (crypto.subtle.supports è undefined). Esegui il probe di 'sign', 'verify', 'generateKey', 'importKey', 'exportKey' o 'getPublicKey' (o passa un dizionario ContextParams come SubtleCrypto.supports('sign', { name: 'ML-DSA-65', context })).
  • Formato della chiave nel fallback ('raw'): anche se i browser che supportano Modern WebCrypto accettano anche 'raw-public' su 'Ed25519' e 'ECDSA', utilizza 'raw' nei percorsi di fallback che hanno come target i browser meno recenti.
  • Separazione dei domini nel fallback: gli algoritmi WebCrypto classici ('Ed25519' e 'ECDSA') non leggono una proprietà context dal dizionario degli algoritmi (WebIDL ignora silenziosamente le proprietà del dizionario sconosciute su Algorithm e EcdsaParams). Anteponi un'intestazione context con prefisso di lunghezza al payload nel percorso di fallback in modo che la separazione dei domini si comporti in modo coerente in entrambe le suite.
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);
}