Web Cryptography API (crypto.subtle)를 사용하여 양자 내성 (ML-DSA) 또는 기존 ('Ed25519', 'ECDSA') 비대칭 키를 사용하여 브라우저에서 메시지, 토큰 또는 아티팩트에 서명하고 확인합니다. 디지털 서명은 공유 비밀 키 없이도 데이터 무결성과 서명자 진위성을 보장합니다. 공유 보안 비밀을 설정하고 기밀 페이로드를 암호화하려면 WebCrypto를 사용하여 클라이언트 측에서 데이터 암호화를 참고하세요.
1. 알고리즘 선택 및 키 쌍 생성
ML-DSA (NIST FIPS 204에서 표준화된 모듈 격자 기반 디지털 서명 알고리즘)는 기존 컴퓨터와 양자 컴퓨터의 공격에 모두 저항하는 양자 내성 격자 기반 디지털 서명을 제공합니다.
범용 서명에는 기본적으로 'ML-DSA-65'를 사용합니다. 대역폭이 제한된 채널에서 더 작은 공개 키와 서명이 필요하고 128비트 보안으로 충분한 경우 'ML-DSA-44'를 선택하고, 보안 정책 요구사항에 NIST 카테고리 5 (CNSA 2.0) 규정 준수가 필요한 경우 'ML-DSA-87'를 선택합니다.
| 알고리즘 이름 | 보안 수준 및 사용 사례 | 공개 키 ('raw-public') |
서명 크기 |
|---|---|---|---|
'ML-DSA-44' |
NIST 카테고리 2 (~128비트 보안), 가장 작은 키 및 서명 | 1312바이트 | 2420바이트 |
'ML-DSA-65' |
NIST 카테고리 3 (~192비트 보안), 권장 기본값 | 1,952바이트 | 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매개변수를 사용하지 않습니다. generateKey()로 키 쌍을 생성할 때['sign', 'verify']를 전달합니다.
const signerKeyPair = await crypto.subtle.generateKey(
'ML-DSA-65',
false,
['sign', 'verify'],
);
2. 키 내보내기 및 가져오기
'ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87'는 importKey() 및 exportKey()에서 NotSupportedError를 사용하여 기존 'raw' 형식을 거부합니다. 항상 명시적 형식을 사용하여 키를 내보내거나 가져옵니다.
'raw-public': 공개 키 바이트 (1312,1952또는2592바이트)입니다. 공개 키를 가져올 때 키 사용으로['verify']를 전달합니다.'raw-seed': 세 가지 ML-DSA 알고리즘 모두에 대한 32바이트 비공개 키 시드입니다.'raw'및'raw-private'는 모두 ML-DSA에서NotSupportedError를 발생시킵니다.'raw-seed','pkcs8'(54바이트) 또는'jwk'(kty: 'AKP')를 사용하여 비공개 키를 저장하거나 전송합니다. 비공개 키를 가져올 때 키 사용으로['sign']를 전달합니다(['sign', 'verify']를importKey()에 전달하면SyntaxError가 발생함).'spki','pkcs8','jwk': 세 가지 ML-DSA 알고리즘 모두에서 지원됩니다.
공개 키 바이트를 직렬화하거나 인증자와 공유할 수 있도록 항상 '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가 필요한 경우 ['verify']를 사용하여 crypto.subtle.getPublicKey()를 호출합니다.
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()는 인증자가crypto.subtle.sign()중에 사용된 것과 정확히 동일한context바이트를 전달하는 경우에만true를 반환합니다.
대체 전략
기준 타겟이 아직 SubtleCrypto.supports() 또는 ML-DSA를 지원하지 않는 경우 정적 SubtleCrypto.supports() 메서드를 사용하여 지원을 감지하고 기존 'Ed25519' (또는 'Ed25519' 지원 이전의 브라우저를 타겟팅하는 경우 'P-256' 및 'SHA-256'가 포함된 'ECDSA')로 대체합니다.
crypto.subtle가 아닌SubtleCrypto생성자에서SubtleCrypto.supports()를 호출합니다.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를 지원하는 브라우저는'Ed25519'및'ECDSA'에서'raw-public'도 허용하지만, 이전 브라우저를 타겟팅하는 대체 경로에서는'raw'를 사용합니다. - 대체 도메인 분리: 기존 WebCrypto 알고리즘 (
'Ed25519'및'ECDSA')은 알고리즘 사전에서context속성을 읽지 않습니다 (WebIDL은Algorithm및EcdsaParams에서 알 수 없는 사전 속성을 자동으로 무시함). 대체 경로의 페이로드에 길이 프리픽스가 있는context헤더를 추가하여 도메인 분리가 두 제품군에서 일관되게 작동하도록 합니다.
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);
}