Podpisywanie i weryfikowanie danych za pomocą WebCrypto

Funkcje internetowe: Kryptografia internetowa

Używaj interfejsu Web Cryptography API (crypto.subtle) do podpisywania i weryfikowania wiadomości, tokenów lub artefaktów w przeglądarce za pomocą odpornych na kwanty (ML-DSA) lub klasycznych ('Ed25519', 'ECDSA') kluczy asymetrycznych. Podpisy cyfrowe gwarantują integralność danych i autentyczność podpisującego bez konieczności udostępniania tajnych kluczy. Informacje o ustalaniu wspólnych obiektów tajnych i szyfrowaniu poufnych danych znajdziesz w artykule Szyfrowanie danych po stronie klienta za pomocą WebCrypto.

1. Wybieranie algorytmu i generowanie pary kluczy

ML-DSA (Module-Lattice-Based Digital Signature Algorithm, ustandaryzowany w NIST FIPS 204) zapewnia postkwantowe podpisy cyfrowe oparte na sieci, które są odporne na ataki zarówno ze strony komputerów klasycznych, jak i kwantowych.

Domyślnie używaj 'ML-DSA-65' do podpisywania do zwykłych obciążeń. Wybierz 'ML-DSA-44', gdy potrzebujesz mniejszych kluczy publicznych i podpisów w kanałach o ograniczonej przepustowości, a 128-bitowe zabezpieczenia są wystarczające, lub 'ML-DSA-87', gdy wymagania wynikające z zasad bezpieczeństwa wymagają zgodności z kategorią 5 NIST (CNSA 2.0).

Nazwa algorytmu Poziom bezpieczeństwa i przypadek użycia Klucz publiczny ('raw-public') Rozmiar podpisu
'ML-DSA-44' Kategoria 2 NIST (zabezpieczenia ~128-bitowe); najmniejsze klucze i podpisy 1312 bajtów 2420 bajtów
'ML-DSA-65' Kategoria 3 NIST (zabezpieczenia ~192-bitowe); zalecane ustawienie domyślne 1952 bajty 3309 bajtów
'ML-DSA-87' NIST Category 5 (zabezpieczenia ~256-bitowe); najwyższy margines bezpieczeństwa 2592 bajty 4627 bajtów
  • Przekaż 'ML-DSA-44', 'ML-DSA-65' lub 'ML-DSA-87' jako nazwę algorytmu (nazwy sprzed standaryzacji, takie jak 'Dilithium' lub 'CRYSTALS-Dilithium', powodują błąd NotSupportedError).
  • Ani ML-DSA, ani 'Ed25519' nie przyjmują parametru hash.
  • Podczas generowania pary kluczy za pomocą generateKey() podaj ['sign', 'verify']:
const signerKeyPair = await crypto.subtle.generateKey(
  'ML-DSA-65',
  false,
  ['sign', 'verify'],
);

2. Eksportowanie i importowanie kluczy

'ML-DSA-44', 'ML-DSA-65' i 'ML-DSA-87' odrzucają starszy format 'raw' w importKey() i exportKey() z NotSupportedError. Zawsze eksportuj lub importuj klucze, używając jawnego formatu:

  • 'raw-public': bajty klucza publicznego (1312, 1952 lub 2592 bajtów). Podczas importowania klucza publicznego przekaż ['verify'] jako użycie klucza.
  • 'raw-seed': 32-bajtowy klucz prywatny dla wszystkich 3 algorytmów ML-DSA. Zarówno 'raw', jak i 'raw-private' zgłaszają błąd NotSupportedError w przypadku ML-DSA – przechowuj lub przesyłaj klucze prywatne za pomocą 'raw-seed', 'pkcs8' (54 bajty) lub 'jwk' (kty: 'AKP'). Podczas importowania klucza prywatnego przekaż ['sign'] jako użycie klucza (przekazanie ['sign', 'verify'] do importKey() zgłasza błąd SyntaxError).
  • 'spki', 'pkcs8' i 'jwk': obsługiwane we wszystkich 3 algorytmach ML-DSA.

Zawsze eksportuj klucz publiczny weryfikacji za pomocą 'raw-public', aby bajty klucza publicznego mogły zostać zserializowane lub udostępnione weryfikatorom:

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

Jeśli masz tylko prywatny klucz podpisywania CryptoKey (nawet z extractable: false) i potrzebujesz odpowiadającego mu publicznego klucza CryptoKey, wywołaj crypto.subtle.getPublicKey() z ['verify']:

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

3. Podpisywanie i weryfikowanie ładunków

Wywołaj crypto.subtle.sign() z kluczem prywatnym, aby wygenerować podpis, i crypto.subtle.verify() z kluczem publicznym, aby go sprawdzić:

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

Rozdzielanie domen za pomocą ContextParams

Aby zapobiec ponownemu odtworzeniu podpisu utworzonego w jednym celu w innej części aplikacji lub protokołu, która korzysta z tej samej pary kluczy, powiąż podpis z context separacji domen.

Zamiast zwykłego ciągu znaków z nazwą algorytmu przekaż słownik ContextParams ({ name, context }) do sign() i verify(), gdzie context to BufferSource (Uint8Array lub 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,
);
  • Limit 255 bajtów: context akceptuje od 0 do 255 bajtów (pominięcie context jest równoznaczne z przekazaniem pustego new Uint8Array(0)). Przekazanie więcej niż 255 bajtów powoduje odrzucenie z OperationError.
  • Wymagane dopasowanie ścisłe: crypto.subtle.verify() zwraca true tylko wtedy, gdy weryfikator przekazuje dokładnie te same bajty context użyte podczas crypto.subtle.sign().

Strategie zastępcze

Jeśli Twój cel Baseline nie obsługuje jeszcze SubtleCrypto.supports() ani ML-DSA, wykryj obsługę za pomocą statycznej metody SubtleCrypto.supports() i wróć do klasycznego 'Ed25519' (lub 'ECDSA' z 'P-256' i 'SHA-256', gdy kierujesz reklamy na przeglądarki przed obsługą 'Ed25519'):

  • Wywołaj SubtleCrypto.supports() w konstruktorze SubtleCrypto, a nie w crypto.subtle: globalThis.SubtleCrypto?.supports?.('sign', 'ML-DSA-65') to synchroniczna metoda statyczna zwracająca wartość logiczną (crypto.subtle.supports to undefined). Wywołaj 'sign', 'verify', 'generateKey', 'importKey', 'exportKey' lub 'getPublicKey' (albo przekaż słownik ContextParams, np. SubtleCrypto.supports('sign', { name: 'ML-DSA-65', context })).
  • Format klucza w przypadku zastępczego ('raw'): przeglądarki obsługujące Modern WebCrypto akceptują też 'raw-public' w przypadku 'Ed25519' i 'ECDSA', ale w ścieżkach zastępczych kierowanych na starsze przeglądarki używaj 'raw'.
  • Separacja domen w przypadku zastępczym: klasyczne algorytmy WebCrypto ('Ed25519' i 'ECDSA') nie odczytują właściwości context ze słownika algorytmów (WebIDL ignoruje nieznane właściwości słownika w Algorithm i EcdsaParams). Dodaj nagłówek context z prefiksem długości do ładunku w ścieżce zastępczej, aby separacja domen działała spójnie w obu pakietach.
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);
}