본문 바로가기
프로그래밍/JavaScript

JS에서 RSA 이용하기(키 페어 생성/암호화) - Web Crypto API

by 페이지다운 2022. 4. 29.
반응형

국내에서는 거의 정보가 없어서 올려본다.

 

종단간 암호화 채팅 서비스를 만들어보려고 프론트엔드에서 RSA 암호화를 할 방법이 없나 알아보았는데 암호화 자체는 가능하지만 RSA 키 페어를 생성할 방법이 마땅치가 않았다. 백엔드에서 RSA 키 페어를 생성해주면 종단간 암호화는 아무런 의미가 없기 때문이다.

 

그래서 방법을 알아보다가 거의 유일하게 발견한 방법이 이 API였다. 브라우저에서 지원하는 API라서 신뢰하고 써도 될 것이다.

 

SubtleCrypto에 대한 자세한 정보는 MDN에 나와 있으므로 참고해도 좋다.

 

이 API가 지원하는 RSA 체계는 다음과 같다.

  • RSA-OAEP (암호화)
  • RSASSA-PKCS1-v1_5 (전자서명)
  • RSA-PSS (전자서명)

1. Key Pair 생성

이 API를 이용하는 이유기도 하다. 다음 코드를 참고하면 된다.

const pemHF = {
    public: {
        header: '-----BEGIN PUBLIC KEY-----',
        footer: '-----END PUBLIC KEY-----',
    },
    private: {
        header: '-----BEGIN PRIVATE KEY-----',
        footer: '-----END PRIVATE KEY-----',
    },
};

const arrayBufferToStr = (buf) => {
    return String.fromCharCode.apply(null, new Uint8Array(buf));
};

const exportKey = async (keyType, key) => {
    const exported = await window.crypto.subtle.exportKey(keyType === 'public' ? 'spki' : 'pkcs8', key);
    const exportedAsString = arrayBufferToStr(exported);
    const exportedAsBase64 = window.btoa(exportedAsString);
    const pemExported = pemHF[keyType].header + '\n' + exportedAsBase64 + '\n' + pemHF[keyType].footer;
    return pemExported;
};

const createKeyPair = async () => {
    const keyPair = await window.crypto.subtle.generateKey(
        {
            name: 'RSA-OAEP',
            modulusLength: 2048,
            publicExponent: new Uint8Array([1, 0, 1]),
            hash: 'SHA-256',
        },
        true,
        ['encrypt', 'decrypt']
    );
    const publicKey = await exportKey('public', keyPair.publicKey);
    const privateKey = await exportKey('private', keyPair.privateKey);
    return { publickey: publicKey, privateKey: privateKey };
};

키 페어를 생성하는 함수는 window.crypto.subtle.generateKey 함수이다. 이 함수가 받는 파라미터는 algorithm, extractable, keyUsages 세개인데 RSA를 이용할 때는 RsaHashedKeyGenParams 형식에 따른 객체를 넘기면 된다.

 

RsaHashedKeyGenParams 객체가 요구하는 키와 값은 다음과 같다.

  • name: RSA 체계 종류. 위에서 언급했던 세가지 중 하나를 넣으면 된다.
  • modulusLength: 키 길이. 최소 2048이어야 한다. 요즘엔 4096을 권장하며, 더욱 보안을 중시하면 8192를 사용하는 경우도 있다.
  • publicExponent: 지수. 65537이 대중적이므로 new Uint8Array([1, 0, 1])를 상수로 넣고 있으면 된다. 0000 0001, 0000 0000, 0000 0001이 이어져서 65537이 된다.
  • hash: 해시 함수. SHA-256, SHA-384, SHA-512 중 선택 가능. 이 diget 함수의 역할은 암호화 시 선택적으로 넣는 label 값을 해시하는데 이용된다. 

extractable은 window.crypto.subtle.exportKey 함수를 통해 키를 추출할 것인지의 여부인데 우리는 당연히 true이다.

 

keyUsages는 키의 용도. 우리는 encrypt와 decrypt 둘 다 사용할 것이다. 만약 서명을 할 생각이라면 encrypt와 decrypt가 아닌 sign과 verify를 넣어야 한다.

 

exportKey 함수에서의 window.crypto.subtle.exportKey는 키를 추출하는 용도이다. 공개키의 포맷은 spki (Subject Public Key Info), 비밀키의 포맷은 pkcs8 (PKCS #8) 라는 것만 주의. 여기서 우리는 ArrayBuffer로 된 키를 문자열(유니코드)로 바꾸고 이를 base64로 변환하게 된다. 그 후에는 pem 형식에 맞춰 헤더와 푸터를 붙인다.

 

이 함수를 실행하면 리턴값은 다음과 같다.

{
    "publickey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApkdPiGPxdfylFbNWowq/YMc3Z5jUDKLuqHcmFU0KbaIGvO9O+BRRDTbRJuBYXgpa7ga9SE7SLChJDu5k4kI+aP2aNu2xs7Mc+vMzHfuzBtIhIh3/8EFI2dQbuOx9OmcEa23+WdritWWed455lmslFb2AktrPqWMgpuYohbPwiksLp8oBWmJvUav+9ungsD9XyhzuC63kFx85CFjtQYBZ7H19qUdy0ai62P9erI3TUOCO0Q/47npQnpZD9nUZxoa/otO9UPeSYcjFGJFBVxJkWzc2IXRq6QvFdbz2KCscFUi6t6gKTkaCtCovHHYyQOPmMZkOKRHXS9kPJVYQ0MH2NwIDAQAB\n-----END PUBLIC KEY-----",
    "privateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmR0+IY/F1/KUVs1ajCr9gxzdnmNQMou6odyYVTQptoga87074FFENNtEm4FheClruBr1ITtIsKEkO7mTiQj5o/Zo27bGzsxz68zMd+7MG0iEiHf/wQUjZ1Bu47H06ZwRrbf5Z2uK1ZZ53jnmWayUVvYCS2s+pYyCm5iiFs/CKSwunygFaYm9Rq/726eCwP1fKHO4LreQXHzkIWO1BgFnsfX2pR3LRqLrY/16sjdNQ4I7RD/juelCelkP2dRnGhr+i071Q95JhyMUYkUFXEmRbNzYhdGrpC8V1vPYoKxwVSLq3qApORoK0Ki8cdjJA4+YxmQ4pEddL2Q8lVhDQwfY3AgMBAAECggEAA4vknvfkVlJ9W7LuHeu3wzx2HdtWz0iWWh9+2Qt1aOGaaGM0rJPgwROY1TRwJ6lSFF7IByFiccpC+XLAnuOWaZTaU8hkEH1S5YNUZS2EijIaWAKKl+w8iopvzbtPItsmby6MZnKfTT30NrNiPBp5UwP7GKbA5E/J8LInmrQIy414/7C8pMt5r/cx4zNbqjVfKpON4Zzcd5AA0L+A7qNE+Rp87SrFOQm9NIzsNKRBZC/VbtWC8EjABP3oWkMhyK0sfJeMsKjk88O8VV9DFVKhWzgF1ss9Z8c/w1cDbaQwijN1sy7D2m1CwyRvMyeGIrjH1qkDQy3TUbq+HWL0XQHCYQKBgQDXOza0YUWr1qJRAqX2tU007DnSqRfbeqrLLNU1Sahq6KIOW2yU4RL5QUI4z5qtxAj6erT+tAimP0ZPd97sUd2xAaLUubbpLybVbf6bwiEdSFrs4FJT3yU2Hbe0c2nZ1MJ0PTdKYbNBByMe4hJEE5iac9wy5UgI1nJ9BU4dLz9mkQKBgQDFxlQEkfF9XX/x3XlFOqxvk80ed84GVfe/gy4jfCWoi71R/agHG/XvnnSFScNpvt9qF3k87jPtOxl/eGrfp3j4VDC126RKIn/tMUIpqzEuRFA+MH05Ga2AsbqvlUAugr/yjbIEF5fpEd4ozuXyYYF6N0NosNp0ZWtJr5qfkw5ERwKBgQCaQvqGdmF1NDTKU1eYZ4GauOUIs+FBkb3wOYXm15A6b9oW6Df+zQLIW5mXFlUKzkKRJHmtRGQeo6NFOekw/8whHccTKLiMkRsIRfoCsTUCw+VHedAIBRuqmcodL8tiMTfeEDIcwcG0jeUCMY5d9J2ftgLRB7yFeQ49xRujl0WdwQKBgQCXwo0cCKVG7qVMAgeZgOFqqP6f34y/Pd5jHZUc85muELSQotT5x5Hbcjq1QJnrneSv8x08DQjZhB0XvF4+CrN/0pKiKc2SeZCygLblZJDTkOYGIo0kcLi7ZSX8r8lVJ02FIQe1rikKVoSjyceXSwzgrGFUh0jKQjymiVJcGqq65wKBgDPWuhUOncC5P4gHsqDeQ9XnRD3uqdF+gu1liYzoUo/cJitubs6RyDxPuIv2sRnvQca92JsupW6GCIVJgQxH869SbzamCOB/7oLfY/1T3xZ8y1gi4I5+f8R34adKNgGFLkkkZfFbNupzP8fDRq4emAC8cFTfVPNZWFE3PYX5a7zW\n-----END PRIVATE KEY-----"
}

실제 키와 같이 줄바꿈도 되어있는 것을 확인할 수 있다.

2. 암호화 / 복호화

이 키들을 이용해서 암호화와 복호화를 해보도록 하자.

const pemHF = {
    public: {
        header: '-----BEGIN PUBLIC KEY-----',
        footer: '-----END PUBLIC KEY-----',
    },
    private: {
        header: '-----BEGIN PRIVATE KEY-----',
        footer: '-----END PRIVATE KEY-----',
    },
};

const arrayBufferToStr = (buf) => {
    return String.fromCharCode.apply(null, new Uint8Array(buf));
};

const strToArrayBuffer = (str) => {
    const encoder = new TextEncoder();
    return encoder.encode(str).buffer;
};

const encryptRSA = async (text, publicKeyb64) => {
    const binaryDerString = window.atob(publicKeyb64.replace(pemHF.public.footer, '').replace(pemHF.public.header, ''));
    const binaryDer = strToArrayBuffer(binaryDerString);
    const publicKey = await window.crypto.subtle.importKey(
        'spki',
        binaryDer,
        {
            name: 'RSA-OAEP',
            hash: 'SHA-256',
        },
        true,
        ['encrypt']
    );
    const cipher = await window.crypto.subtle.encrypt(
        {
            name: 'RSA-OAEP',
        },
        publicKey,
        strToArrayBuffer(text)
    );
    return window.btoa(arrayBufferToStr(cipher));
};

const decryptRSA = async (cipher, privateKeyb64) => {
    const binaryDerString = window.atob(privateKeyb64.replace(pemHF.private.footer, '').replace(pemHF.private.header, ''));
    const binaryDer = strToArrayBuffer(binaryDerString);
    const privateKey = await window.crypto.subtle.importKey(
        'pkcs8',
        binaryDer,
        {
            name: 'RSA-OAEP',
            hash: 'SHA-256',
        },
        true,
        ['decrypt']
    );
    const text = await window.crypto.subtle.decrypt(
        {
            name: 'RSA-OAEP',
        },
        privateKey,
        strToArrayBuffer(window.atob(cipher))
    );
    return arrayBufferToStr(text);
};

코드가 복잡해 보이지만 실제로 encrypt와 decrypt는 그냥 서로 역순이라고 보면 된다. 이 코드는 공개키를 이용해 암호화하고, 비밀키를 이용해 암호화한다. 반대도 가능하나 본문에는 적지 않는다.

 

아무튼 일단 key를 먼저 import하게 되는데, 그 이전에 pem 형식의 키를 ArrayBuffer로 변환하는 과정이 추가된다.

 

window.subtle.crypto.importKey가 요구하는 파라미터는 format, keyData, algorithm, extractable, keyUsages이다.

 

format은 말 그대로 키의 포맷을 말한다. 앞서 말했듯이 공개 키는 spki, 비밀 키는 pkcs8이다. 앞서 말했듯이 SPKI는 RFC 5280 섹션 4.1에 정의된 Subject Public Key Info를 의미한다. PKCS #8은 RFC 5208에 정의된 포맷을 의미한다.

 

keyData는 키를 말한다. 앞서 ArrayBuffer로 변경한 키를 넣어준다.

 

algorithm은 알고리즘에 대한 정보이다. 객체 형식은 RsaHashedImportParams이다.

  • name: 암호화 체계. 본 API에서 암호화를 지원하는 RSA 체계는 RSA-OAEP가 유일하다.
  • hash: Label diget 함수. SHA-256, SHA-384, SHA-512 중 선택 가능.

extractable은 앞서 키를 생성했을 때처럼 API에서 사용하기 위한 CryptoKey 객체로 추출하면서 이를 다시 추출할지를 물어보는 것이다. 

 

keyUsages는 말 그대로 키를 어디에 쓸 것인지 묻는 것이다. 암호화면 encrypt, 복호화면 decrypt. 둘 다 할거면 둘 다 넣으면 된다.

 

마지막으로 암호화다.

 

window.subtle.crypto.encrypt가 요구하는 파라미터는 algorithm, key, data이다.

 

algorithm은 알고리즘에 대한 정보다. 객체 형식은 RsaOaepParams를 넘기면 된다. 이 객체는 키는 name 하나를 필수적으로 요구하며 앞서 말했듯이 옵션으로 label이라는 키가 있으나 일반적으로는 쓰지 않는다. 궁금하면 MDN 참고 바람.

 

key는 앞서 추출한 CryptoKey 객체를 넣으면 된다.

 

data는 암호화할 데이터로 텍스트를 ArrayBuffer로 바꿔준 뒤 넣으면 된다.

 

그 이후에는 결과물로 나온 ArrayBuffer을 유니코드 문자열로 변경한 후 base64로 변환하게 된다.

 

 

이번에 이렇게 Web Crypto API에서 RSA 키 페어를 생성하고 암호화하는 법을 알아보았다. 전자서명도 있고, 해시도 있고, AES를 이용한 대칭 암호화도 있는데 차근차근 올려보도록 하겠다.

 

질문 있으면 댓글 달아주시기 바랍니다.

반응형

댓글