Ký và xác minh dữ liệu bằng WebCrypto

Tính năng web: Mật mã học trên web

Sử dụng Web Cryptography API (crypto.subtle) để ký và xác minh thông báo, mã thông báo hoặc cấu phần phần mềm trong trình duyệt bằng khoá bất đối xứng kháng lượng tử (ML-DSA) hoặc khoá bất đối xứng cổ điển ('Ed25519', 'ECDSA'). Chữ ký số đảm bảo tính toàn vẹn của dữ liệu và tính xác thực của người ký mà không cần khoá bí mật dùng chung. Để thiết lập khoá bí mật dùng chung và mã hoá tải trọng bí mật, hãy xem bài viết Mã hoá dữ liệu phía máy khách bằng WebCrypto.

1. Chọn một thuật toán và tạo một cặp khoá

ML-DSA (Giải thuật ký số dựa trên mạng tinh thể mô-đun, được chuẩn hoá trong NIST FIPS 204) cung cấp chữ ký số dựa trên mạng tinh thể hậu lượng tử có khả năng chống lại các cuộc tấn công của cả máy tính cổ điển và máy tính lượng tử.

Sử dụng 'ML-DSA-65' theo mặc định cho việc ký chung. Chọn 'ML-DSA-44' khi bạn cần khoá công khai và chữ ký nhỏ hơn trên các kênh có băng thông hạn chế và mức bảo mật 128 bit là đủ, hoặc chọn 'ML-DSA-87' khi yêu cầu theo chính sách bảo mật yêu cầu tuân thủ NIST Danh mục 5 (CNSA 2.0).

Tên thuật toán Mức an ninh và trường hợp sử dụng Khoá công khai ('raw-public') Kích thước chữ ký
'ML-DSA-44' NIST Loại 2 (bảo mật ~128 bit); khoá và chữ ký nhỏ nhất 1312 byte 2420 byte
'ML-DSA-65' NIST Loại 3 (bảo mật ~192 bit); mặc định được đề xuất 1952 byte 3309 byte
'ML-DSA-87' NIST Loại 5 (bảo mật ~256 bit); biên độ bảo mật cao nhất 2592 byte 4627 byte
  • Truyền 'ML-DSA-44', 'ML-DSA-65' hoặc 'ML-DSA-87' làm tên thuật toán (tên trước khi chuẩn hoá như 'Dilithium' hoặc 'CRYSTALS-Dilithium' sẽ gửi NotSupportedError).
  • Cả ML-DSA và 'Ed25519' đều không nhận tham số hash.
  • Truyền ['sign', 'verify'] khi tạo cặp khoá bằng generateKey():
const signerKeyPair = await crypto.subtle.generateKey(
  'ML-DSA-65',
  false,
  ['sign', 'verify'],
);

2. Xuất và nhập khoá

'ML-DSA-44', 'ML-DSA-65' và 'ML-DSA-87' từ chối định dạng 'raw' cũ trong importKey() và exportKey() bằng NotSupportedError. Luôn xuất hoặc nhập khoá bằng định dạng rõ ràng:

  • 'raw-public': Byte khoá công khai (1312, 1952 hoặc 2592 byte). Truyền ['verify'] làm mục đích sử dụng khoá khi nhập khoá công khai.
  • 'raw-seed': Hạt giống khoá riêng tư 32 byte cho cả ba thuật toán ML-DSA. Cả 'raw' và 'raw-private' đều gửi NotSupportedError trên ML-DSA – lưu trữ hoặc truyền khoá riêng tư bằng 'raw-seed', 'pkcs8' (54 byte) hoặc 'jwk' (kty: 'AKP'). Truyền ['sign'] làm mức sử dụng khoá khi nhập khoá riêng tư (truyền ['sign', 'verify'] đến importKey() sẽ gửi SyntaxError).
  • 'spki', 'pkcs8' và 'jwk': Được hỗ trợ cho cả ba thuật toán ML-DSA.

Luôn xuất khoá công khai xác minh bằng 'raw-public' để có thể chuyển đổi tuần tự hoặc chia sẻ byte khoá công khai với trình xác minh:

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

Nếu bạn chỉ giữ một CryptoKey ký riêng tư (ngay cả với extractable: false) và cần CryptoKey công khai tương ứng, hãy gọi crypto.subtle.getPublicKey() bằng ['verify']:

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

3. Ký và xác minh tải trọng

Gọi crypto.subtle.sign() bằng khoá riêng tư để tạo chữ ký và crypto.subtle.verify() bằng khoá công khai để kiểm tra chữ ký:

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

Phân tách miền bằng ContextParams

Để ngăn chữ ký được tạo cho một mục đích được phát lại trong một phần khác của ứng dụng hoặc giao thức có cùng cặp khoá, hãy liên kết chữ ký với context phân tách miền.

Thay vì một chuỗi tên thuật toán đơn thuần, hãy truyền một từ điển ContextParams ({ name, context }) đến sign() và verify(), trong đó context là BufferSource (Uint8Array hoặc 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,
);
  • Giới hạn 255 byte: context chấp nhận từ 0 đến 255 byte (việc bỏ qua context tương đương với việc truyền một new Uint8Array(0) trống). Việc truyền nhiều hơn 255 byte sẽ bị từ chối bằng OperationError.
  • Yêu cầu khớp chính xác: crypto.subtle.verify() chỉ trả về true khi trình xác minh truyền chính xác cùng một byte context được dùng trong crypto.subtle.sign().

Chiến lược dự phòng

Nếu mục tiêu Baseline của bạn chưa hỗ trợ SubtleCrypto.supports() hoặc ML-DSA, hãy phát hiện khả năng hỗ trợ bằng phương thức static SubtleCrypto.supports() và quay lại 'Ed25519' cổ điển (hoặc 'ECDSA' với 'P-256' và 'SHA-256' khi nhắm đến các trình duyệt trước khi hỗ trợ 'Ed25519'):

  • Gọi SubtleCrypto.supports() trên hàm khởi tạo SubtleCrypto, không phải trên crypto.subtle: globalThis.SubtleCrypto?.supports?.('sign', 'ML-DSA-65') là một phương thức tĩnh đồng bộ trả về một boolean (crypto.subtle.supports là undefined). Thăm dò 'sign', 'verify', 'generateKey', 'importKey', 'exportKey' hoặc 'getPublicKey' (hoặc truyền một từ điển ContextParams như SubtleCrypto.supports('sign', { name: 'ML-DSA-65', context })).
  • Định dạng khoá trong phương án dự phòng ('raw'): Mặc dù các trình duyệt hỗ trợ WebCrypto hiện đại cũng chấp nhận 'raw-public' trên 'Ed25519' và 'ECDSA', nhưng hãy sử dụng 'raw' trong các đường dẫn dự phòng nhắm đến các trình duyệt cũ.
  • Tách miền trong phương án dự phòng: Các thuật toán WebCrypto cổ điển ('Ed25519' và 'ECDSA') không đọc thuộc tính context từ từ điển thuật toán (WebIDL tự động bỏ qua các thuộc tính từ điển không xác định trên Algorithm và EcdsaParams). Thêm tiêu đề context có tiền tố độ dài vào tải trọng trong đường dẫn dự phòng để việc tách miền hoạt động nhất quán trên cả hai bộ thuật toán.
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);
}