WebCrypto를 사용하여 클라이언트 측에서 데이터 암호화

웹 기능: 웹 암호화

Web Cryptography API (crypto.subtle)를 사용하여 양자 내성 키 캡슐화 메커니즘 (KEM) 또는 기존 키 계약을 통해 신뢰할 수 없는 네트워크에서 공유 비밀번호를 설정하고, AEAD (Authenticated Encryption with Associated Data)를 사용하여 브라우저에서 민감한 정보를 암호화합니다. 메시지 또는 아티팩트를 암호화하지 않고 서명하고 확인하려면 WebCrypto로 데이터 서명 및 확인을 참조하세요.

엔드 투 엔드 봉투 암호화 흐름은 두 단계로 나뉩니다.

  • 일회성 수신자 설정 (1~2단계): 수신자는 키 쌍을 생성하고, 향후 세션을 위해 IndexedDB에 CryptoKeyPair를 유지하고, 공개 키 ('raw-public')를 내보내 발신자와 공유합니다.
  • 메시지별 암호화 (3~4단계): 각 페이로드에 대해 발신자는 수신자의 공개 키를 가져오고, encapsulateKey()를 호출하여 새로운 일회성 공유 키와 암호문 캡슐을 생성하고, 새로운 12바이트 IV로 페이로드를 암호화하고, { capsule, iv, ciphertext }를 수신자에게 전송합니다.

1. KEM 알고리즘 선택 및 수신자 키 쌍 생성

양측이 키를 결합하는 디피-헬먼 키 합의 (deriveKey())와 달리 키 캡슐화 메커니즘 (KEM)은 수신자의 공개 키를 사용하여 단일 encapsulateKey() 호출에서 무작위 공유 비밀번호와 암호문 캡슐을 모두 생성합니다. 수신자는 decapsulateKey()를 사용하여 암호문 캡슐에서 동일한 공유 키를 복구합니다.

기본적으로 'MLKEM768-X25519'를 사용합니다. 이 알고리즘은 포스트 퀀텀 ML-KEM (NIST FIPS 203에서 표준화됨)과 기존 'X25519'를 결합하므로 두 알고리즘 중 하나가 유지되는 한 공유 보안 비밀이 안전하게 유지됩니다. 정책 요구사항에 하이브리드 구성요소가 없는 순수한 FIPS 203 출력이 필요한 경우 독립형 'ML-KEM-768'를 선택하고, 정책 요구사항에 NIST 카테고리 5 (CNSA 2.0) 규정 준수가 필요한 경우 'ML-KEM-1024'를 선택합니다.

알고리즘 이름 보안 수준 및 사용 사례 공개 키 ('raw-public') 비공개 시드 ('raw-seed') 암호문 크기
'MLKEM768-X25519' 하이브리드 포스트 퀀텀 ('ML-KEM-768') + 클래식 ('X25519'), 권장 기본값 1216바이트 32바이트 1,120바이트
'ML-KEM-768' NIST 카테고리 3 (~192비트 보안), 독립형 포스트 퀀텀 KEM 1184바이트 64바이트 1,088바이트
'ML-KEM-1024' NIST 카테고리 5 (~256비트 보안), 최고 보안 마진 1,568바이트 64바이트 1,568바이트
  • 'MLKEM768-X25519', 'ML-KEM-768' 또는 'ML-KEM-1024'를 알고리즘 이름으로 전달합니다('X-Wing' 또는 'X25519MLKEM768', 'ML-KEM-512'과 같은 별칭은 NotSupportedError를 발생시킴).
  • KEM 키 쌍을 생성할 때 extractable: false 및 ['encapsulateKey', 'decapsulateKey'] (또는 ['encapsulateBits', 'decapsulateBits'])를 전달합니다. 추출 불가능한 CryptoKey 및 CryptoKeyPair 객체는 비공개 키 바이트를 내보내지 않고도 구조화된 클론 생성 (objectStore.put(recipientKeyPair, 'recipient-kem-key'))을 통해 IndexedDB에서 직접 세션 간에 유지될 수 있습니다.
const recipientKeyPair = await crypto.subtle.generateKey(
  'MLKEM768-X25519',
  false,
  ['encapsulateKey', 'decapsulateKey'],
);

2. 키 내보내기 및 가져오기

최신 WebCrypto 알고리즘 ('MLKEM768-X25519', 'ML-KEM-768', 'ML-KEM-1024', 'ChaCha20-Poly1305')은 importKey() 및 exportKey()에서 기존 'raw' 형식을 거부하고 NotSupportedError를 반환합니다. 항상 명시적 키 형식을 지정합니다.

  • 'raw-public': 공개 키 바이트 (1216, 1184 또는 1568바이트)입니다. KEM 공개 키를 가져올 때 ['encapsulateKey'] (또는 ['encapsulateBits'])를 전달합니다.
  • 'raw-seed': 비공개 키 시드 바이트 ('MLKEM768-X25519'의 경우 32바이트, 'ML-KEM-768' 및 'ML-KEM-1024'의 경우 64바이트). KEM 비공개 키를 가져올 때 ['decapsulateKey'] (또는 ['decapsulateBits'])를 전달합니다(['encapsulateKey', 'decapsulateKey']를 importKey() 또는 getPublicKey()에 전달하면 SyntaxError가 발생함). 'raw'와 'raw-private' 모두 KEM 비공개 키에서 NotSupportedError를 발생시킵니다. 'MLKEM768-X25519'는 'raw-public', 'raw-seed', 'jwk'만 지원하며 'spki' 또는 'pkcs8'는 지원하지 않습니다.
  • 'raw-secret': 대칭 보안 비밀 키 바이트 ('ChaCha20-Poly1305', 'AES-GCM' 및 'HKDF'에서 허용됨)

공개 키 바이트를 직렬화 (예: new Uint8Array(recipientPublicKeyBytes).toBase64() 또는 16진수를 통해 Base64로)하고 발신자와 공유할 수 있도록 'raw-public'를 사용하여 수신자의 공개 키를 내보냅니다.

// Export the recipient's public key bytes
const recipientPublicKeyBytes = await crypto.subtle.exportKey(
  'raw-public',
  recipientKeyPair.publicKey,
);

// Import the recipient's public key on the sender (usage must be ['encapsulateKey'])
const importedPublicKey = await crypto.subtle.importKey(
  'raw-public',
  recipientPublicKeyBytes,
  'MLKEM768-X25519',
  true,
  ['encapsulateKey'],
);

비공개 CryptoKey만 보유하고 있고(extractable: false 포함) 해당하는 공개 CryptoKey가 필요한 경우 crypto.subtle.getPublicKey()를 호출합니다.

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

3. 공유 키 캡슐화 및 캡슐화 해제

encapsulateKey() 및 decapsulateKey()는 sharedKeyAlgorithm에 지정된 알고리즘('raw-secret' 형식)을 사용하여 32바이트(256비트) KEM 공유 보안 비밀을 CryptoKey로 가져옵니다. 'ChaCha20-Poly1305' 또는 'AES-GCM'를 직접 전달하여 AEAD 암호화 키를 생성합니다.

// Sender encapsulates a 256-bit AEAD key and produces a ciphertext capsule
const { sharedKey: senderKey, ciphertext: encapsulatedCiphertext } =
  await crypto.subtle.encapsulateKey(
    'MLKEM768-X25519',
    importedPublicKey,
    'ChaCha20-Poly1305', // Or 'AES-GCM'
    false,
    ['encrypt', 'decrypt'],
  );

// Recipient decapsulates the ciphertext capsule to recover the identical AEAD key
const recipientKey = await crypto.subtle.decapsulateKey(
  'MLKEM768-X25519',
  recipientKeyPair.privateKey,
  encapsulatedCiphertext,
  'ChaCha20-Poly1305',
  false,
  ['encrypt', 'decrypt'],
);

'HKDF'를 사용한 도메인 분리

공유 키를 특정 프로토콜 컨텍스트 (info 및 salt)에 바인딩하거나 단일 캡슐화에서 여러 하위 키를 파생하려면 'HKDF'를 ['deriveKey'] 사용과 함께 sharedKeyAlgorithm로 전달한 다음 crypto.subtle.deriveKey()를 호출합니다.

const { sharedKey: hkdfBaseKey, ciphertext } = await crypto.subtle.encapsulateKey(
  'MLKEM768-X25519',
  importedPublicKey,
  'HKDF',
  false,
  ['deriveKey'],
);

const aeadKey = await crypto.subtle.deriveKey(
  {
    name: 'HKDF',
    hash: 'SHA-256',
    salt: new Uint8Array(32),
    info: new TextEncoder().encode('envelope-v1'),
  },
  hkdfBaseKey,
  { name: 'AES-GCM', length: 256 },
  false,
  ['encrypt', 'decrypt'],
);

4. 페이로드 암호화 및 복호화 ('ChaCha20-Poly1305' 및 'AES-GCM')

모든 암호화 작업에 새로운 암호화 임의 12바이트(96비트) 초기화 벡터(iv)를 사용하여 AEAD 알고리즘 ('ChaCha20-Poly1305' 또는 'AES-GCM')으로 페이로드 데이터를 암호화합니다. (iv, key) 쌍을 재사용하지 마세요. 전용 AES 하드웨어 가속 없이 기기 전반에서 일관된 상수 시간 성능을 원한다면 'ChaCha20-Poly1305'를 선택하고, 기존 WebCrypto 또는 FIPS 140 배포와 상호 운용하는 경우에는 'AES-GCM'를 선택합니다.

  • 'ChaCha20-Poly1305': 고정된 256비트 (32바이트) 키를 사용합니다 (generateKey('ChaCha20-Poly1305', extractable, ['encrypt', 'decrypt'])는 문자열을 사용하거나 { name: 'ChaCha20-Poly1305' }는 length 속성 없이 사용). iv는 정확히 12바이트여야 하며 128비트 Poly1305 인증 태그가 암호문에 추가됩니다.
  • 'AES-GCM': generateKey() 또는 deriveKey()를 호출할 때 { name: 'AES-GCM', length: 256 }를 전달하고 encrypt() 및 decrypt()를 호출할 때 12바이트 iv를 전달합니다.
  • additionalData (AAD): additionalData를 통해 암호화되지 않은 메타데이터 (예: 레코드 ID, 수신자 ID, 프로토콜 버전)를 인증 태그에 결합하여 암호문 또는 메타데이터를 조작하면 decrypt()가 OperationError로 거부합니다.
/**
 * Encrypts UTF-8 text with an AEAD CryptoKey ('ChaCha20-Poly1305' or 'AES-GCM').
 * @param {CryptoKey} key
 * @param {string} plaintext
 * @param {Uint8Array} [additionalData]
 */
export async function encryptMessage(key, plaintext, additionalData = new Uint8Array(0)) {
  // Always generate a fresh 12-byte (96-bit) IV per message
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encoded = new TextEncoder().encode(plaintext);

  const ciphertextBuffer = await crypto.subtle.encrypt(
    {
      name: key.algorithm.name,
      iv,
      additionalData,
    },
    key,
    encoded,
  );

  return { iv, ciphertext: new Uint8Array(ciphertextBuffer) };
}

/**
 * Decrypts and authenticates an AEAD payload. Throws OperationError if tampered.
 * @param {CryptoKey} key
 * @param {Uint8Array} iv
 * @param {Uint8Array} ciphertext
 * @param {Uint8Array} [additionalData]
 */
export async function decryptMessage(key, iv, ciphertext, additionalData = new Uint8Array(0)) {
  const plaintextBuffer = await crypto.subtle.decrypt(
    {
      name: key.algorithm.name,
      iv,
      additionalData,
    },
    key,
    ciphertext,
  );

  return new TextDecoder().decode(plaintextBuffer);
}

대체 전략

SubtleCrypto.supports()SubtleCrypto.supports()encapsulateKey()'ChaCha20-Poly1305''X25519''X25519''HKDF''AES-GCM''ECDH''P-256'

  • crypto.subtle가 아닌 SubtleCrypto 생성자에서 SubtleCrypto.supports() 호출: globalThis.SubtleCrypto?.supports?.('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305')는 불리언을 반환하는 동기식 정적 메서드입니다 (crypto.subtle.supports는 undefined). SubtleCrypto.supports()는 작업별 매개변수를 검증합니다. 기본 알고리즘 문자열은 'encapsulateKey', 'decapsulateKey', 'generateKey', 'importKey', 'exportKey', 'getPublicKey'에서 작동하는 반면 'encrypt' 또는 'decrypt'를 직접 프로빙하려면 iv (예: SubtleCrypto.supports('encrypt', { name: 'ChaCha20-Poly1305', iv: new Uint8Array(12) }))가 필요합니다.
  • 대체 키 형식 ('raw'): 최신 WebCrypto를 지원하는 브라우저는 'X25519' 및 'ECDH'에서 'raw-public'도 허용하지만, 이전 브라우저를 타겟팅하는 대체 경로에서는 'raw'를 사용합니다.
  • 기존 대체 ('X25519' + 'HKDF' + 'AES-GCM'): SubtleCrypto.supports()를 사용할 수 없거나 false를 반환하는 경우 발신자의 임시 'X25519' 키 쌍을 생성하고, 임시 공개 키를 캡슐 바이트로 내보내고, deriveBits({ name: 'X25519', public: peerPublicKey }, privateKey, 256)를 통해 256비트를 파생하고, 이러한 비트를 'HKDF' ('SHA-256')를 통해 전달하여 256비트 'AES-GCM' 키를 파생합니다.
const KEM_PUBLIC_KEY_ALGORITHMS = {
  1184: 'ML-KEM-768',
  1216: 'MLKEM768-X25519',
  1568: 'ML-KEM-1024',
};

export function supportsPostQuantumEnvelope() {
  return (
    typeof globalThis.SubtleCrypto?.supports === 'function' &&
    SubtleCrypto.supports('encapsulateKey', 'MLKEM768-X25519', 'ChaCha20-Poly1305')
  );
}

export async function generateRecipientKeys() {
  if (supportsPostQuantumEnvelope()) {
    const keyPair = await crypto.subtle.generateKey(
      'MLKEM768-X25519',
      false,
      ['encapsulateKey', 'decapsulateKey'],
    );
    const publicKeyBytes = new Uint8Array(
      await crypto.subtle.exportKey('raw-public', keyPair.publicKey),
    );
    return { suite: 'MLKEM768-X25519+ChaCha20-Poly1305', keyPair, publicKeyBytes };
  }

  // Classical fallback: X25519 key agreement + HKDF + AES-GCM
  const keyPair = await crypto.subtle.generateKey('X25519', false, ['deriveBits']);
  const publicKeyBytes = new Uint8Array(
    await crypto.subtle.exportKey('raw', keyPair.publicKey),
  );
  return { suite: 'X25519+HKDF+AES-GCM', keyPair, publicKeyBytes };
}

async function deriveFallbackAesKey(privateKey, peerPublicKey, infoBytes = new Uint8Array(0)) {
  const sharedSecret = await crypto.subtle.deriveBits(
    { name: 'X25519', public: peerPublicKey },
    privateKey,
    256,
  );
  const hkdfKey = await crypto.subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveKey']);
  return crypto.subtle.deriveKey(
    {
      name: 'HKDF',
      hash: 'SHA-256',
      salt: new Uint8Array(32),
      info: infoBytes,
    },
    hkdfKey,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt'],
  );
}

export async function encapsulateEnvelopeKey(
  recipientPublicKeyOrBytes,
  infoBytes = new Uint8Array(0),
) {
  const pubKey =
    recipientPublicKeyOrBytes instanceof CryptoKey
      ? recipientPublicKeyOrBytes
      : await (() => {
          const bytes =
            recipientPublicKeyOrBytes instanceof Uint8Array
              ? recipientPublicKeyOrBytes
              : new Uint8Array(recipientPublicKeyOrBytes);
          const kemName = KEM_PUBLIC_KEY_ALGORITHMS[bytes.byteLength];
          return kemName
            ? crypto.subtle.importKey('raw-public', bytes, kemName, true, ['encapsulateKey'])
            : crypto.subtle.importKey('raw', bytes, 'X25519', true, []);
        })();

  if (pubKey.algorithm.name !== 'X25519') {
    const { sharedKey, ciphertext } = await crypto.subtle.encapsulateKey(
      pubKey.algorithm.name,
      pubKey,
      'ChaCha20-Poly1305',
      false,
      ['encrypt', 'decrypt'],
    );
    return { sharedKey, capsuleBytes: new Uint8Array(ciphertext) };
  }

  // Classical fallback: generate ephemeral X25519 key pair and send ephemeral public key as capsule
  const ephemeral = await crypto.subtle.generateKey('X25519', false, ['deriveBits']);
  const capsuleBytes = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey));
  const sharedKey = await deriveFallbackAesKey(ephemeral.privateKey, pubKey, infoBytes);
  return { sharedKey, capsuleBytes };
}

export async function decapsulateEnvelopeKey(
  recipientPrivateKey,
  capsuleBytes,
  infoBytes = new Uint8Array(0),
) {
  if (recipientPrivateKey.algorithm.name !== 'X25519') {
    return crypto.subtle.decapsulateKey(
      recipientPrivateKey.algorithm.name,
      recipientPrivateKey,
      capsuleBytes,
      'ChaCha20-Poly1305',
      false,
      ['encrypt', 'decrypt'],
    );
  }

  const ephemeralPubKey = await crypto.subtle.importKey('raw', capsuleBytes, 'X25519', true, []);
  return deriveFallbackAesKey(recipientPrivateKey, ephemeralPubKey, infoBytes);
}