WebCrypto로 데이터 서명 및 확인

웹 기능: 웹 암호화

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