Daten mit WebCrypto signieren und verifizieren

Webfunktionen: Web Cryptography

Mit der Web Cryptography API (crypto.subtle) können Sie Nachrichten, Tokens oder Artefakte im Browser mit quantenresistenten (ML-DSA) oder klassischen ('Ed25519', 'ECDSA') asymmetrischen Schlüsseln signieren und verifizieren. Digitale Signaturen garantieren die Datenintegrität und die Authentizität des Signierenden, ohne dass gemeinsame geheime Schlüssel erforderlich sind. Informationen zum Einrichten von gemeinsamen Secrets und zum Verschlüsseln vertraulicher Nutzlasten finden Sie unter Daten clientseitig mit WebCrypto verschlüsseln.

1. Algorithmus auswählen und Schlüsselpaar generieren

ML-DSA (Module-Lattice-Based Digital Signature Algorithm, standardisiert in NIST FIPS 204) bietet Post-Quanten-gitterbasierte digitale Signaturen, die Angriffen sowohl von klassischen als auch von Quantencomputern standhalten.

Verwenden Sie standardmäßig 'ML-DSA-65' für allgemeine Zwecke. Wählen Sie 'ML-DSA-44', wenn Sie kleinere öffentliche Schlüssel und Signaturen über bandbreitenbeschränkte Kanäle benötigen und eine 128-Bit-Sicherheit ausreicht, oder 'ML-DSA-87', wenn eine Sicherheitsrichtlinie die Einhaltung von NIST-Kategorie 5 (CNSA 2.0) erfordert.

Algorithmusname Sicherheitsniveau und Anwendungsfall Öffentlicher Schlüssel ('raw-public') Signaturgröße
'ML-DSA-44' NIST-Kategorie 2 (~128-Bit-Sicherheit); kleinste Schlüssel und Signaturen 1.312 Byte 2.420 Byte
'ML-DSA-65' NIST-Kategorie 3 (~192-Bit-Sicherheit); empfohlener Standardwert 1.952 Byte 3.309 Byte
'ML-DSA-87' NIST-Kategorie 5 (~256-Bit-Sicherheit); höchste Sicherheitsmarge 2.592 Byte 4.627 Byte
  • Übergeben Sie 'ML-DSA-44', 'ML-DSA-65' oder 'ML-DSA-87' als Algorithmusnamen. Bei Namen vor der Standardisierung wie 'Dilithium' oder 'CRYSTALS-Dilithium' wird NotSupportedError ausgegeben.
  • Weder ML-DSA noch 'Ed25519' akzeptieren einen hash-Parameter.
  • Übergeben Sie ['sign', 'verify'], wenn Sie ein Schlüsselpaar mit generateKey() generieren:
const signerKeyPair = await crypto.subtle.generateKey(
  'ML-DSA-65',
  false,
  ['sign', 'verify'],
);

2. Schlüssel exportieren und importieren

'ML-DSA-44', 'ML-DSA-65' und 'ML-DSA-87' lehnen das alte 'raw'-Format in importKey() und exportKey() mit einem NotSupportedError ab. Exportieren oder importieren Sie Schlüssel immer mit einem expliziten Format:

  • 'raw-public': Byte des öffentlichen Schlüssels (1312-, 1952- oder 2592-Byte). Übergeben Sie ['verify'] als Schlüsselverwendung, wenn Sie einen öffentlichen Schlüssel importieren.
  • 'raw-seed': 32-Byte-Seed für den privaten Schlüssel für alle drei ML-DSA-Algorithmen. Sowohl 'raw' als auch 'raw-private' geben bei ML-DSA NotSupportedError aus. Speichern oder übertragen Sie private Schlüssel mit 'raw-seed', 'pkcs8' (54 Byte) oder 'jwk' (kty: 'AKP'). Übergeben Sie ['sign'] als Schlüsselverwendung, wenn Sie einen privaten Schlüssel importieren. Wenn Sie ['sign', 'verify'] an importKey() übergeben, wird SyntaxError ausgegeben.
  • 'spki', 'pkcs8' und 'jwk': Unterstützt für alle drei ML-DSA-Algorithmen.

Exportieren Sie den öffentlichen Schlüssel für die Bestätigung immer mit 'raw-public', damit die Byte des öffentlichen Schlüssels serialisiert oder mit Prüfern geteilt werden können:

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

Wenn Sie nur einen privaten Signatur-CryptoKey haben (auch mit extractable: false) und den entsprechenden öffentlichen CryptoKey benötigen, rufen Sie crypto.subtle.getPublicKey() mit ['verify'] auf:

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

3. Nutzlasten signieren und verifizieren

Rufen Sie crypto.subtle.sign() mit dem privaten Schlüssel auf, um eine Signatur zu erstellen, und crypto.subtle.verify() mit dem öffentlichen Schlüssel, um sie zu prüfen:

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

Domänen-Trennung mit ContextParams

Um zu verhindern, dass eine für einen bestimmten Zweck erstellte Signatur in einem anderen Teil Ihrer Anwendung oder Ihres Protokolls, der dasselbe Schlüsselpaar verwendet, wiedergegeben wird, binden Sie die Signatur an eine Domain-Separation-context.

Übergeben Sie anstelle eines einfachen Algorithmusnamensstrings ein ContextParams-Dictionary ({ name, context }) an sign() und verify(), wobei context ein BufferSource (Uint8Array oder ArrayBuffer) ist:

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,
);
  • Limit von 255 Byte: context akzeptiert 0 bis 255 Byte. Wenn context weggelassen wird, entspricht dies der Übergabe eines leeren new Uint8Array(0). Wenn mehr als 255 Byte übergeben werden, wird die Anfrage mit OperationError abgelehnt.
  • Genaue Übereinstimmung erforderlich: crypto.subtle.verify() gibt true nur zurück, wenn der Prüfer genau dieselben context-Bytes übergibt, die während crypto.subtle.sign() verwendet wurden.

Fallback-Strategien

Wenn Ihr Baseline-Ziel SubtleCrypto.supports() oder ML-DSA noch nicht unterstützt, können Sie die Unterstützung mit der statischen SubtleCrypto.supports()-Methode erkennen und auf das klassische 'Ed25519' (oder 'ECDSA' mit 'P-256' und 'SHA-256', wenn Sie Browser vor der 'Ed25519'-Unterstützung verwenden) zurückgreifen:

  • Rufen Sie SubtleCrypto.supports() im SubtleCrypto-Konstruktor auf, nicht in crypto.subtle: globalThis.SubtleCrypto?.supports?.('sign', 'ML-DSA-65') ist eine synchrone statische Methode, die einen booleschen Wert zurückgibt (crypto.subtle.supports ist undefined). Prüfen Sie 'sign', 'verify', 'generateKey', 'importKey', 'exportKey' oder 'getPublicKey' (oder übergeben Sie ein ContextParams-Wörterbuch wie SubtleCrypto.supports('sign', { name: 'ML-DSA-65', context })).
  • Schlüsselformat im Fallback ('raw'): Browser, die Modern WebCrypto unterstützen, akzeptieren zwar auch 'raw-public' für 'Ed25519' und 'ECDSA', aber in Fallback-Pfaden für ältere Browser sollten Sie 'raw' verwenden.
  • Domain-Trennung im Fallback: Klassische WebCrypto-Algorithmen ('Ed25519' und 'ECDSA') lesen keine context-Eigenschaft aus dem Algorithmus-Dictionary (WebIDL ignoriert unbekannte Dictionary-Eigenschaften bei Algorithm und EcdsaParams stillschweigend). Stellen Sie der Nutzlast im Fallback-Pfad einen context-Header mit Längenpräfix voran, damit die Domain-Trennung in beiden Suites einheitlich funktioniert.
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);
}