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