ลงชื่อและยืนยันข้อมูลด้วย WebCrypto

ฟีเจอร์บนเว็บ: Web Cryptography

ใช้ Web Cryptography API (crypto.subtle) เพื่อรับรองและยืนยันข้อความ โทเค็น หรืออาร์ติแฟกต์ในเบราว์เซอร์โดยใช้คีย์แบบไม่สมมาตรที่ทนทานต่อการโจมตีจากคอมพิวเตอร์ควอนตัม (ML-DSA) หรือแบบคลาสสิก ('Ed25519', 'ECDSA') ลายเซ็นดิจิทัลรับประกันความสมบูรณ์ของข้อมูลและความถูกต้องของผู้ลงนามโดยไม่ต้องใช้คีย์ลับที่แชร์ ดูวิธีสร้างข้อมูลลับที่ใช้ร่วมกันและเข้ารหัสเพย์โหลดที่เป็นความลับได้ที่หัวข้อเข้ารหัสข้อมูลฝั่งไคลเอ็นต์ด้วย WebCrypto

1. เลือกอัลกอริทึมและสร้างคู่คีย์

ML-DSA (Module-Lattice-Based Digital Signature Algorithm ซึ่งได้มาตรฐานใน NIST FIPS 204) มีลายเซ็นดิจิทัลแบบแลตทิซที่ทนทานต่อการโจมตีจากคอมพิวเตอร์ควอนตัมซึ่งทนทานต่อการโจมตีจากทั้งคอมพิวเตอร์แบบคลาสสิกและคอมพิวเตอร์ควอนตัม

ใช้ 'ML-DSA-65' โดยค่าเริ่มต้นสำหรับการลงนามอเนกประสงค์ เลือก 'ML-DSA-44' เมื่อต้องการคีย์สาธารณะและลายเซ็นที่มีขนาดเล็กลงผ่านช่องทางที่มีแบนด์วิดท์จำกัด และการรักษาความปลอดภัยแบบ 128 บิตก็เพียงพอแล้ว หรือเลือก 'ML-DSA-87' เมื่อนโยบายความปลอดภัยกำหนดให้ต้องปฏิบัติตามข้อกำหนดด้านนโยบาย NIST หมวดหมู่ 5 (CNSA 2.0)

ชื่ออัลกอริทึม ระดับการรักษาความปลอดภัยและกรณีการใช้งาน คีย์สาธารณะ ('raw-public') ขนาดลายเซ็น
'ML-DSA-44' NIST หมวดหมู่ 2 (ความปลอดภัย ~128 บิต) คีย์และลายเซ็นที่เล็กที่สุด 1312 ไบต์ 2420 ไบต์
'ML-DSA-65' NIST Category 3 (ความปลอดภัย ~192 บิต) ค่าเริ่มต้นที่แนะนำ 1952 ไบต์ 3309 ไบต์
'ML-DSA-87' NIST หมวดหมู่ 5 (การรักษาความปลอดภัย ~256 บิต) ขอบเขตการรักษาความปลอดภัยสูงสุด 2592 ไบต์ 4627 ไบต์
  • ส่ง 'ML-DSA-44', 'ML-DSA-65' หรือ 'ML-DSA-87' เป็นชื่ออัลกอริทึม (ชื่อก่อนการกำหนดมาตรฐาน เช่น 'Dilithium' หรือ 'CRYSTALS-Dilithium' จะแสดง NotSupportedError)
  • ทั้ง ML-DSA และ 'Ed25519' ไม่ใช้พารามิเตอร์ hash
  • ส่ง ['sign', 'verify'] เมื่อสร้างคู่คีย์ด้วย generateKey():
const signerKeyPair = await crypto.subtle.generateKey(
  'ML-DSA-65',
  false,
  ['sign', 'verify'],
);

2. ส่งออกและนำเข้าคีย์

'ML-DSA-44', 'ML-DSA-65' และ 'ML-DSA-87' ปฏิเสธรูปแบบ 'raw' เดิมใน importKey() และ exportKey() ด้วย NotSupportedError ส่งออกหรือนำเข้าคีย์โดยใช้รูปแบบที่ชัดเจนเสมอ ดังนี้

  • 'raw-public': ไบต์คีย์สาธารณะ (1312, 1952 หรือ 2592 ไบต์) ส่ง ['verify'] เป็นการใช้งานคีย์เมื่อนำเข้าคีย์สาธารณะ
  • 'raw-seed': ซีดคีย์ส่วนตัวขนาด 32 ไบต์สำหรับอัลกอริทึม ML-DSA ทั้ง 3 รายการ ทั้ง 'raw' และ 'raw-private' จะแสดง NotSupportedError ใน ML-DSA ซึ่งจะจัดเก็บหรือส่งคีย์ส่วนตัวโดยใช้ 'raw-seed', 'pkcs8' (54 ไบต์) หรือ 'jwk' (kty: 'AKP') ส่ง ['sign'] เป็นการใช้งานคีย์เมื่อนำเข้าคีย์ส่วนตัว (การส่ง ['sign', 'verify'] ไปยัง importKey() จะแสดง SyntaxError)
  • 'spki', 'pkcs8' และ 'jwk': รองรับอัลกอริทึม ML-DSA ทั้ง 3 รายการ

ส่งออกคีย์สาธารณะสำหรับการยืนยันด้วย 'raw-public' เสมอ เพื่อให้สามารถเรียงลำดับไบต์ของคีย์สาธารณะหรือแชร์กับผู้ตรวจสอบได้ ดังนี้

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

หากคุณมีเพียง CryptoKey การรับรองส่วนตัว (แม้จะมี extractable: false) และต้องการ CryptoKey สาธารณะที่เกี่ยวข้อง ให้เรียกใช้ crypto.subtle.getPublicKey() ด้วย ['verify'] ดังนี้

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

3. ลงชื่อและยืนยันเพย์โหลด

เรียกใช้ crypto.subtle.sign() ด้วยคีย์ส่วนตัวเพื่อสร้างลายเซ็น และเรียกใช้ crypto.subtle.verify() ด้วยคีย์สาธารณะเพื่อตรวจสอบลายเซ็น ดังนี้

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

การแยกโดเมนด้วย ContextParams

หากต้องการป้องกันไม่ให้มีการใช้ลายเซ็นที่สร้างขึ้นเพื่อวัตถุประสงค์หนึ่งซ้ำในส่วนอื่นของแอปพลิเคชันหรือโปรโตคอลที่ใช้คู่คีย์เดียวกัน ให้เชื่อมโยงลายเซ็นกับ context การแยกโดเมน

แทนที่จะใช้สตริงชื่ออัลกอริทึมเปล่าๆ ให้ส่งพจนานุกรม ContextParams ({ name, context }) ไปยัง sign() และ verify() โดยที่ context คือ BufferSource (Uint8Array หรือ 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,
);
  • ขีดจำกัด 255 ไบต์: context ยอมรับ 0 ถึง 255 ไบต์ (การละเว้น context เทียบเท่ากับการส่ง new Uint8Array(0) ที่ว่างเปล่า) การส่งมากกว่า 255 ไบต์จะถูกปฏิเสธด้วย OperationError
  • ต้องตรงกันทุกประการ: crypto.subtle.verify() จะแสดงผล true ก็ต่อเมื่อผู้ตรวจสอบส่งไบต์ context ที่ตรงกันทุกประการกับที่ใช้ในระหว่าง crypto.subtle.sign()

กลยุทธ์สำรอง

หากเป้าหมาย Baseline ยังไม่รองรับ SubtleCrypto.supports() หรือ ML-DSA ให้ตรวจหาการรองรับโดยใช้เมธอด SubtleCrypto.supports() แบบคงที่ และเปลี่ยนกลับไปใช้ 'Ed25519' แบบคลาสสิก (หรือ 'ECDSA' ที่มี 'P-256' และ 'SHA-256' เมื่อกำหนดเป้าหมายเบราว์เซอร์ก่อนการรองรับ 'Ed25519') ดังนี้

  • เรียกใช้ SubtleCrypto.supports() ในคอนสตรัคเตอร์ SubtleCrypto ไม่ใช่ใน crypto.subtle: globalThis.SubtleCrypto?.supports?.('sign', 'ML-DSA-65') เป็นเมธอดแบบคงที่แบบซิงโครนัสที่แสดงผลบูลีน (crypto.subtle.supports คือ undefined) ตรวจสอบ 'sign', 'verify', 'generateKey', 'importKey', 'exportKey' หรือ 'getPublicKey' (หรือส่งพจนานุกรม ContextParams เช่น SubtleCrypto.supports('sign', { name: 'ML-DSA-65', context }))
  • รูปแบบคีย์ในโหมดสำรอง ('raw'): แม้ว่าเบราว์เซอร์ที่รองรับ WebCrypto สมัยใหม่จะยอมรับ 'raw-public' ใน 'Ed25519' และ 'ECDSA' ด้วย แต่ให้ใช้ 'raw' ในเส้นทางสำรองที่กำหนดเป้าหมายเป็นเบราว์เซอร์รุ่นเก่า
  • การแยกโดเมนในโหมดสำรอง: อัลกอริทึม WebCrypto แบบคลาสสิก ('Ed25519' และ 'ECDSA') จะไม่อ่านพร็อพเพอร์ตี้ context จากพจนานุกรมอัลกอริทึม (WebIDL จะละเว้นพร็อพเพอร์ตี้พจนานุกรมที่ไม่รู้จักใน Algorithm และ EcdsaParams โดยไม่แจ้งให้ทราบ) เพิ่มส่วนหัว context ที่มีคำนำหน้าความยาวลงในเพย์โหลดในเส้นทางสำรองเพื่อให้การแยกโดเมนทำงานสอดคล้องกันในทั้ง 2 ชุด
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);
}