Gegevens ondertekenen en verifiëren met WebCrypto

Webfuncties: Webcryptografie

Gebruik de Web Cryptography API (crypto.subtle) om berichten, tokens of artefacten in de browser te ondertekenen en te verifiëren met quantumresistente (ML-DSA) of klassieke ('Ed25519', 'ECDSA') asymmetrische sleutels. Digitale handtekeningen garanderen de gegevensintegriteit en de authenticiteit van de ondertekenaar zonder dat er gedeelde geheime sleutels nodig zijn. Ga naar Gegevens aan de clientzijde versleutelen met WebCrypto voor meer informatie over hoe je gedeelde geheimen instelt en vertrouwelijke payloads versleutelt.

1. Kies een algoritme en genereer een sleutelpaar

ML-DSA (Module-Lattice-Based Digital Signature Algorithm, gestandaardiseerd in NIST FIPS 204) biedt post-quantum digitale handtekeningen op basis van roosters die bestand zijn tegen aanvallen door zowel klassieke als quantumcomputers.

Gebruik standaard 'ML-DSA-65' voor algemene ondertekening. Kies 'ML-DSA-44' als je kleinere openbare sleutels en handtekeningen nodig hebt via kanalen met beperkte bandbreedte en 128-bits beveiliging voldoende is, of 'ML-DSA-87' als een beveiligingsbeleid naleving van NIST-categorie 5 (CNSA 2.0) vereist.

Algoritmenaam Beveiligingsniveau en use case Openbare sleutel ('raw-public') Grootte van handtekening
'ML-DSA-44' NIST-categorie 2 (~128-bits beveiliging); kleinste sleutels en handtekeningen 1312 bytes 2420 bytes
'ML-DSA-65' NIST-categorie 3 (~192-bits beveiliging); aanbevolen standaard 1952 bytes 3309 bytes
'ML-DSA-87' NIST-categorie 5 (~256-bits beveiliging); hoogste beveiligingsmarge 2592 bytes 4627 bytes
  • Geef 'ML-DSA-44', 'ML-DSA-65' of 'ML-DSA-87' door als de algoritmenaam (namen van vóór de standaardisatie, zoals 'Dilithium' of 'CRYSTALS-Dilithium', genereren NotSupportedError).
  • Noch ML-DSA, noch 'Ed25519' neemt een hash-parameter.
  • Geef ['sign', 'verify'] door als je een sleutelpaar genereert met generateKey():
const signerKeyPair = await crypto.subtle.generateKey(
  'ML-DSA-65',
  false,
  ['sign', 'verify'],
);

2. Sleutels exporteren en importeren

'ML-DSA-44', 'ML-DSA-65' en 'ML-DSA-87' wijzen de verouderde 'raw'-indeling af in importKey() en exportKey() met een NotSupportedError. Exporteer of importeer sleutels altijd met een expliciete indeling:

  • 'raw-public': Bytes van openbare sleutel (1312, 1952 of 2592 bytes). Geef ['verify'] door als sleutelgebruik als je een openbare sleutel importeert.
  • 'raw-seed': Privésleutel-seed van 32 bytes voor alle 3 de ML-DSA-algoritmen. Zowel 'raw' als 'raw-private' genereren NotSupportedError op ML-DSA. Sla privésleutels op of verzend ze met 'raw-seed', 'pkcs8' (54 bytes) of 'jwk' (kty: 'AKP'). Geef ['sign'] door als sleutelgebruik bij het importeren van een privésleutel (als je ['sign', 'verify'] doorgeeft aan importKey(), wordt SyntaxError gegenereerd).
  • 'spki', 'pkcs8' en 'jwk': Ondersteund voor alle 3 de ML-DSA-algoritmen.

Exporteer de openbare verificatiesleutel altijd met 'raw-public', zodat de bytes van de openbare sleutel kunnen worden geserialiseerd of gedeeld met verificateurs:

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

Als je alleen een privésleutel voor ondertekening CryptoKey hebt (zelfs met extractable: false) en de bijbehorende openbare sleutel CryptoKey nodig hebt, roep je crypto.subtle.getPublicKey() aan met ['verify']:

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

3. Payloads ondertekenen en verifiëren

Roep crypto.subtle.sign() aan met de privésleutel om een handtekening te maken en crypto.subtle.verify() met de openbare sleutel om deze te controleren:

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

Domeinscheiding met ContextParams

Als je wilt voorkomen dat een handtekening die voor een bepaald doel is gemaakt, opnieuw wordt afgespeeld in een ander deel van je app of protocol dat hetzelfde sleutelpaar deelt, bind je de handtekening aan een domeinscheiding context.

In plaats van een tekenreeks met een kale algoritmenaam geef je een ContextParams-woordenboek ({ name, context }) door aan sign() en verify(), waarbij context een BufferSource is (Uint8Array of 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,
);
  • Limiet van 255 bytes: context accepteert 0 tot 255 bytes (als je context weglaat, is dat hetzelfde als een lege new Uint8Array(0) doorgeven). Als je meer dan 255 bytes doorgeeft, wordt dit geweigerd met OperationError.
  • Exacte overeenkomst vereist: crypto.subtle.verify() retourneert true alleen als de verificateur exact dezelfde context bytes doorgeeft die zijn gebruikt tijdens crypto.subtle.sign().

Reservestrategieën

Als je Baseline-doel SubtleCrypto.supports() of ML-DSA nog niet ondersteunt, spoor je ondersteuning op met de statische SubtleCrypto.supports()-methode en val je terug op klassieke 'Ed25519' (of 'ECDSA' met 'P-256' en 'SHA-256' als je browsers target van vóór 'Ed25519'-ondersteuning):

  • Roep SubtleCrypto.supports() aan op de SubtleCrypto-constructor, niet op crypto.subtle: globalThis.SubtleCrypto?.supports?.('sign', 'ML-DSA-65') is een synchrone statische methode die een Booleaanse waarde retourneert (crypto.subtle.supports is undefined). Test 'sign', 'verify', 'generateKey', 'importKey', 'exportKey' of 'getPublicKey' (of geef een ContextParams-woordenboek door, zoals SubtleCrypto.supports('sign', { name: 'ML-DSA-65', context })).
  • Sleutelindeling in de fallback ('raw'): Hoewel browsers die Modern WebCrypto ondersteunen ook 'raw-public' accepteren op 'Ed25519' en 'ECDSA', gebruik je 'raw' in fallback-paden die oudere browsers targeten.
  • Domeinscheiding in de fallback: Klassieke WebCrypto-algoritmen ('Ed25519' en 'ECDSA') lezen geen context-property uit het algoritmedictioary (WebIDL negeert onbekende dictionary-property's op Algorithm en EcdsaParams). Voeg een context-header met lengtevoorvoegsel toe aan de payload in je fallback-pad, zodat domeinscheiding consistent werkt in beide suites.
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);
}