Menandatangani dan memverifikasi data dengan WebCrypto

Fitur web: Web Cryptography

Gunakan Web Cryptography API (crypto.subtle) untuk menandatangani dan memverifikasi pesan, token, atau artefak di browser menggunakan kunci asimetris yang tahan kuantum (ML-DSA) atau klasik ('Ed25519', 'ECDSA'). Tanda tangan digital menjamin integritas data dan keaslian penanda tangan tanpa memerlukan kunci rahasia bersama. Untuk membuat rahasia bersama dan mengenkripsi payload rahasia, lihat Mengenkripsi data sisi klien dengan WebCrypto.

1. Memilih algoritma dan membuat pasangan kunci

ML-DSA (Module-Lattice-Based Algoritma Tanda Tangan Digital, yang distandardisasi dalam NIST FIPS 204) menyediakan tanda tangan digital berbasis lattice pasca-kuantum yang tahan terhadap serangan dari komputer klasik dan kuantum.

Gunakan 'ML-DSA-65' secara default untuk penandatanganan tujuan umum. Pilih 'ML-DSA-44' jika Anda memerlukan kunci publik dan tanda tangan yang lebih kecil melalui saluran dengan bandwidth terbatas dan keamanan 128-bit sudah cukup, atau 'ML-DSA-87' jika persyaratan kebijakan memerlukan kepatuhan terhadap NIST Category 5 (CNSA 2.0).

Nama algoritma Tingkat keamanan & kasus penggunaan Kunci publik ('raw-public') Ukuran tanda tangan
'ML-DSA-44' NIST Kategori 2 (keamanan ~128-bit); kunci dan tanda tangan terkecil 1312 byte 2.420 byte
'ML-DSA-65' NIST Kategori 3 (keamanan ~192-bit); default yang direkomendasikan 1952 byte 3309 byte
'ML-DSA-87' NIST Kategori 5 (keamanan ~256-bit); margin keamanan tertinggi 2.592 byte 4.627 byte
  • Teruskan 'ML-DSA-44', 'ML-DSA-65', atau 'ML-DSA-87' sebagai nama algoritma (nama pra-standardisasi seperti 'Dilithium' atau 'CRYSTALS-Dilithium' menampilkan NotSupportedError).
  • ML-DSA dan 'Ed25519' tidak menggunakan parameter hash.
  • Teruskan ['sign', 'verify'] saat membuat pasangan kunci dengan generateKey():
const signerKeyPair = await crypto.subtle.generateKey(
  'ML-DSA-65',
  false,
  ['sign', 'verify'],
);

2. Mengekspor dan mengimpor kunci

'ML-DSA-44', 'ML-DSA-65', dan 'ML-DSA-87' menolak format 'raw' lama di importKey() dan exportKey() dengan NotSupportedError. Selalu ekspor atau impor kunci menggunakan format eksplisit:

  • 'raw-public': Byte kunci publik (byte 1312, 1952, atau 2592). Teruskan ['verify'] sebagai penggunaan kunci saat mengimpor kunci publik.
  • 'raw-seed': Seed kunci pribadi 32 byte untuk ketiga algoritma ML-DSA. 'raw' dan 'raw-private' menampilkan NotSupportedError di ML-DSA—menyimpan atau mengirimkan kunci pribadi menggunakan 'raw-seed', 'pkcs8' (54 byte), atau 'jwk' (kty: 'AKP'). Teruskan ['sign'] sebagai penggunaan kunci saat mengimpor kunci pribadi (meneruskan ['sign', 'verify'] ke importKey() akan menampilkan SyntaxError).
  • 'spki', 'pkcs8', dan 'jwk': Didukung untuk ketiga algoritma ML-DSA.

Selalu ekspor kunci publik verifikasi dengan 'raw-public' agar byte kunci publik dapat diserialisasi atau dibagikan kepada pemverifikasi:

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

Jika Anda hanya memiliki CryptoKey penandatanganan pribadi (bahkan dengan extractable: false) dan memerlukan CryptoKey publik yang sesuai, panggil crypto.subtle.getPublicKey() dengan ['verify']:

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

3. Menandatangani dan memverifikasi payload

Panggil crypto.subtle.sign() dengan kunci pribadi untuk menghasilkan tanda tangan, dan crypto.subtle.verify() dengan kunci publik untuk memeriksanya:

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

Pemisahan domain dengan ContextParams

Untuk mencegah tanda tangan yang dibuat untuk satu tujuan diputar ulang di bagian lain aplikasi atau protokol Anda yang menggunakan pasangan kunci yang sama, ikat tanda tangan tersebut ke context pemisahan domain.

Alih-alih string nama algoritma kosong, teruskan kamus ContextParams ({ name, context }) ke sign() dan verify(), dengan context adalah BufferSource (Uint8Array atau 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,
);
  • Batas 255 byte: context menerima 0 hingga 255 byte (menghilangkan context sama dengan meneruskan new Uint8Array(0) kosong). Meneruskan lebih dari 255 byte akan ditolak dengan OperationError.
  • Pencocokan persis diperlukan: crypto.subtle.verify() menampilkan true hanya jika pemverifikasi meneruskan byte context yang sama persis dengan yang digunakan selama crypto.subtle.sign().

Strategi penggantian

Jika target Dasar Pengukuran Anda belum mendukung SubtleCrypto.supports() atau ML-DSA, deteksi dukungan menggunakan metode SubtleCrypto.supports() statis dan gunakan kembali 'Ed25519' klasik (atau 'ECDSA' dengan 'P-256' dan 'SHA-256' saat menargetkan browser sebelum dukungan 'Ed25519'):

  • Panggil SubtleCrypto.supports() pada konstruktor SubtleCrypto, bukan pada crypto.subtle: globalThis.SubtleCrypto?.supports?.('sign', 'ML-DSA-65') adalah metode statis sinkron yang menampilkan boolean (crypto.subtle.supports adalah undefined). Selidiki 'sign', 'verify', 'generateKey', 'importKey', 'exportKey', atau 'getPublicKey' (atau teruskan kamus ContextParams seperti SubtleCrypto.supports('sign', { name: 'ML-DSA-65', context })).
  • Format kunci di penggantian ('raw'): Meskipun browser yang mendukung WebCrypto Modern juga menerima 'raw-public' di 'Ed25519' dan 'ECDSA', gunakan 'raw' di jalur penggantian yang menargetkan browser lama.
  • Pemisahan domain dalam penggantian: Algoritma WebCrypto klasik ('Ed25519' dan 'ECDSA') tidak membaca properti context dari kamus algoritma (WebIDL secara otomatis mengabaikan properti kamus yang tidak diketahui di Algorithm dan EcdsaParams). Tambahkan header context dengan awalan panjang ke payload di jalur penggantian Anda agar pemisahan domain berperilaku konsisten di kedua 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);
}