ใช้ 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);
}