작년 3월, 회사에서 통신 암호화를 구현하게 될 수 있으니 미리 암호화에 대해 공부해보라는 이야기를 들었다. 그때 공부했던 것을 블로그에 남기기도 했는데, 굉장히 흥미롭게 봤던 기억이 난다. 시간이 조금 지나긴 했지만 이번에 회사의 새로운 프로젝트에 들어가면서 좋은 기회로 통신 암호화를 구현해보게 되었다. 역시나 굉장히 재밌게 작업했고, 새롭게 배운것들도 많아서 블로그를 남기면 좋을것같다는 생각이 들었다.
처음에 대표님께서 end-to-end 암호화를 구현하라고 하셔서 의아했다. End-to-end encryption이라고 하면 비밀채팅에서 사용하는 그거 아닌가? 우리 서비스에는 채팅 기능이 없는데 어디에 적용을 하라는 말씀이신거지?
알고보니 클라이언트에서 서버까지 데이터가 평문으로 전달되는 구간이 없도록 암호화를 하라는 의미였다. 말 그대로 끝(클라이언트)에서부터 끝(서버)까지. 하지만 클라이언트에서부터 서버까지는 이미 HTTPS로 암호화가 되어 전달되지 않나? 하는 의문이 들어 질문을 드렸더니, 친절하게 설명해주셨다.

로드밸런서는 요청을 받아 서버에 작업을 고르게 분산시켜주는 역할을 하는 장치다. 로드밸런서가 HTTPS 요청을 받으면, SSL Termination을 실행하여 복호화를 하게 되는데, 이렇게 되면 로드밸런서부터 서버까지는 암호화 되지 않은 평문이 이동하게 된다. 어차피 내부망 안인데 굳이 암호화를 해야하냐고 생각할 수도 있지만, 정보를 가져가려는 사람들은 어떤 방법을 써서든 가져가려고 하기 때문에 중요한 정보라면 어떤 구간이든 평문으로 이동하지 않도록 하는게 안전하다고 하셨다.
그렇다고 이걸 해결할 다른 방법이 없는것은 아니다. 퇴근 후 집에 와서 조금 더 찾아보니 로드밸런서부터 서버까지 HTTPS로 데이터를 전송하는 방법도 찾아볼 수 있었다.

SSL Bridging은 로드밸런서가 받은 요청을 복호화하고 내용을 검사하여 라우팅을 결정 한 뒤, 다시 암호화를 해서 서버로 데이터를 전송한다. 이 방식은 SSL Termination에 비해 보안성은 높지만, 중간에 복호화를 하는 과정이 있으니 클라이언트부터 백엔드까지 완전한 암호화는 아니다. 그리고 로드밸런서와 백엔드 서버 둘 다 SSL 인증서를 관리해야하고 SSL 복호화를 두번하게 되면서 서버에 부하가 증가한다는 단점도 있다.

SSL Passthrough는 로드밸런서가 SSL을 요청을 복호화하지 않고 바로 서버로 전달해주는 방식이다. 세가지 방법 중 제일 안전하지만, 로드밸런서가 HTTPS를 복호화하여 Path를 확인할 수 없기때문에 경로 기반 라우팅이 불가능하다. 백엔드 서버가 한개인 작은 서버라면 일단 Path를 몰라도 서버로 보내고, 서버에서 복호화 한뒤 적절한 API 라우터로 보내는 식으로 해결할 수도 있겠지만, 하나의 도메인 아래에 여러개의 서버를 물리적으로 분리하여 운영하는 큰 서비스의 경우에는 이 방법을 적용하기가 어렵다.
아무튼 이러한 이유로 평문으로 전달되는 구간이 있을 수 있으니, 데이터 자체를 암호화해서 보내면 중간에 공격을 받더라도 개인정보를 안전하게 보호할 수 있게 된다. 우리나라에서는 중요한 개인정보(주민번호, 금융정보 등)는 평문으로 이동하는 구간이 없도록 법으로 정해놨기 때문에, 금융권 등 중요한 정보를 다루는 곳에서는 이런 방법을 사용한다고 한다.
비대칭키 암호화는 개인키와 공개키를 사용하기 때문에, 키를 관리하기가 용이하다. 개인키는 안전하게 보관하고, 공개키만 전달하기 때문에 전달과정에서 키가 유출될 걱정이 없어 매우 안전하다. 다만, 계산이 복잡해서 굉장히 느리고, 암호화 할 수 있는 데이터의 양이 제한적이라는 치명적인 단점이 있다. 이번 프로젝트에서 RSA-2048 알고리즘을 사용했는데, 키를 생성하는데 굉장히 오래걸린다는것을 직접 체험했다 (체감 1초 이상..). 그리고 RSA-2048 기준으로 암호화가 가능한 데이터는 고작 245byte로, 문서나 파일같은 경우는 아예 암호화가 불가능하다.
대칭키 암호화는 빠르고, 대량의 데이터도 암호화가 가능하다. 하지만 암호화를 하는 키와 복호화를 하는 키가 같기 때문에, 키를 전달하는 과정에서 유출이나 탈취의 위험이 있다.
따라서, 데이터를 대칭키로 암호화하고, 대칭키를 비대칭키로 암호화하여 안전하게 전달하는 방식을 채택했다.





서버가 RSA Public Key를 공개해주면, 클라이언트에서 데이터를 암호화할때 사용한 AES 키를 서버의 RSA Public Key로 암호화한다. 암호화한 AES 키는 요청 헤더에 담아 서버로 전달한다.
만약에 서버가 보내주는 응답에 데이터 암호화가 필요하다면? 클라이언트에서 RSA 키를 생성한 후, 서버로 요청을 보낼때 요청 헤더에 클라이언트의 RSA Public Key를 담아 보내주면 된다. 그러면 서버가 데이터를 암호화하고, AES 키를 클라이언트의 RSA Public Key로 암호화하여 응답 헤더에 담아 보내준다.
이번 프로젝트에서는 AES-256과 RSA-2048을 사용했고, 각 암호화 알고리즘의 키를 생성하는것은 node-forge 라이브러리의 도움을 받았다. 꼭 이 라이브러리가 아니더라도, 원하는 암호화 라이브러리를 선택해서 사용하면 되고, 공식 문서와 구글링을 잘 활용하면 문제없이 함수를 구현할 수 있다.
중요한 것은 백엔드와 암호화 세부설정을 맞추고, 키 생성을 할때 세부설정을 제대로 넣어야한다는 것이다. iv를 백엔드에서 정한 규격과는 다르게 설정하거나, AES의 다른 모드를 사용한다거나.. 등등 백엔드와 맞춰놓은 세부설정이 단 한글자만 틀려도 복호화가 불가능해지기 때문에 자칫하면 한참동안 삽질을 하게될 수 있다.
그리고 위에서 말했듯 RSA는 키 생성을 할 때 시간이 굉장히 오래걸린다. 화면이 하얀 여백으로 멈춰있다가 키 생성이 완료되면 그제야 렌더링이 되는데, 이 현상을 방지하기 위해 Web Worker를 사용했다. Web Worker는 메인 스레드와 분리된 백그라운드 스레드로, 복잡한 연산을 메인 스레드 대신 처리하게 할 수 있다. 이것까지 쓰면 글이 너무 길어질것같아서, 일단 언급만 해두겠다. 나중에 기회가 된다면 다른 글로 작성하면 좋을 것 같다.
이번에 진행한 암호화는 개인정보를 포함한 특정 데이터 필드만 암/복호화를 진행해야했다. 따라서 요청,응답 데이터의 어떤 필드가 암/복호화가 되어야하는지 미리 설정을 해주었다. 회원가입 요청을 보내는 코드로 예시를 들어보겠다.
export interface UserSignUpRequest {
email: string
password: string
profile: {
gender: string
}
channel: string
}
요청을 보낼 때, 이러한 구조를 가진 데이터가 서버로 보내진다. 이제 이것과 동일한 구조를 가지지만, 암호화를 해야하는 필드에만 true값을 준 객체를 같이 만들어준다.
export const UserSignUpReqEncConfig = {
email: true,
password: true,
profile: {
gender: true,
},
}
email과 password, gender는 암호화가 되어야하는 개인정보이기 때문에 true값을 주었다. 가입 경로를 의미하는 channel은 개인정보가 아니어서 암호화를 해주지 않아도 된다. 나는 값이 false인 키들도 전부 적게된다면 코드가 길어지며 오히려 가독성이 떨어질 것 같아 쓰지 않았는데, 상황에 따라 적절한 방법을 사용하면 될것같다. 객체가 아니라 암호화를 할 필드를 문자열 배열로 저장하는 등 여러가지 방법을 사용할 수 있다.
interface EncryptionConfig {
requestEncryption?: boolean
responseEncryption?: boolean
encryptedRequestFields?: EncryptedFieldsConfig
encryptedResponseFields?: EncryptedFieldsConfig
}
declare module 'axios' {
export interface AxiosRequestConfig {
encryptionConfig?: EncryptionConfig
}
}
이제 암/복호화할 필드에 대한 가이드를 헤더에 포함할 수 있도록 axios의 요청 설정 객체에 속성을 추가해준다. requestEncryption과 responseEncryption 속성은 각각 요청 시 암호화 필드 존재 여부, 응답의 복호화 필드 존재 여부를 의미하고, encryptedRequestFields와 encryptedRequestFields는 각각 요청에서 암호화되어야 하는 필드, 응답에서 복호화되어야 하는 필드의 정보를 담고있다.
예를 들어서, 위에서 정의한 UserSignUpReqEncConfig대로 요청 데이터를 암호화하고 싶다면, requestEncryption를 true로 설정하고, encryptedRequestFields에 UserSignUpReqEncConfig 객체를 넣어주면 된다.
요청 설정 객체에만 속성을 추가해주면, 응답 객체에서도 접근할 수 있다. 응답 객체에는 따로 설정해주지 않아도 된다.
export const deepCrypt = (data: unknown, aesKey: string, cryptoFn: (value: string, key: string) => string, encryptionConfig?: EncryptedFieldsConfig): unknown => {
if (!data || encryptionConfig === undefined) return data
// 데이터 타입이 배열이면 배열의 요소를 순회
if (Array.isArray(data)) {
return data.map((item) => {
if (Array.isArray(encryptionConfig)) {
return deepCrypt(item, aesKey, cryptoFn, encryptionConfig[0])
}
return item
})
}
// 데이터 타입이 object면 [key, value] 배열로 변환 후 순회
if (typeof data === 'object') {
return Object.fromEntries(
Object.entries(data).map(([key, value]) => {
if (typeof encryptionConfig === 'object' && !Array.isArray(encryptionConfig)) {
const fieldConfig = encryptionConfig[key]
return [key, deepCrypt(value, aesKey, cryptoFn, fieldConfig)]
}
return [key, value]
})
)
}
// 데이터 타입이 string이고, 암호화 필드가 true라면 암호화/복호화 진행
if (typeof data === 'string' && encryptionConfig === true) {
if (encryptionConfig) return cryptoFn(data, aesKey)
}
return data
}
데이터 구조는 단순하지 않다. 많은 정보를 주고받는 요청의 경우, 객체가 중첩될 수도 있고, 배열이 중첩될 수도 있으며, 객체와 배열이 함께 중첩될 수도 있다. 따라서 이 구조를 하나씩 순회하며 암/복호화가 되어야 하는 필드를 만나면 암/복호화를 실행하는 함수를 만들어주었다. 이 함수는 두가지를 전제로 한다.
- 데이터 필드는 객체, 배열 또는 문자열이다.
- 객체 또는 배열 전체를 암호화하지 않는다. 각 속성이 객체 또는 배열 데이터를 가지고 있다면, 문자열 데이터를 만날때까지 중첩된 데이터를 타고 내려간다. 즉, 문자열 값만 암호화한다.
함수는 네개의 인자를 받는다: 암호화를 시킬 데이터, 암호화 시킬 필드에 대한 정보를 가지고 있는 객체, 암/복호화 함수, 암호화를 시킬 때 사용할 AES 키
위 두가지 전제를 바탕으로 암호화를 시킬 데이터와 암호화 정보 객체를 함께 순회한다. 배열이라면 배열 요소를 순회하고, 객체라면 키,값쌍을 배열로 변환하여 순회한다. 문자열을 만나면 필드의 암호화 여부를 확인하고, 필요시 암호화한다.
(이 함수는 회사의 백엔드 개발자분과 합의를 한 내용을 바탕으로 작성했다. 프로젝트에 따라 객체/배열/문자열이 아닌 데이터를 암호화 할 수도 있고, 객체/배열 데이터 전체를 암호화 해야할 수도 있다. 그때는 그 조건에 맞는 함수를 구현해야 한다.)
마지막으로, axios interceptor를 사용하여 나가고 들어오는 모든 요청과 응답을 캐치한다.
instance.interceptors.request.use(
(config) => {
const encryptionConfig = config.encryptionConfig
const rsaKeyPair = getRSAKeyPair()
if (encryptionConfig) {
const { requestEncryption, responseEncryption, encryptedRequestFields } = encryptionConfig
if (requestEncryption && encryptedRequestFields) {
const aesKey = generateAESKey()
if (config.method?.toLowerCase() === 'get' && config.params) {
config.params = deepCrypt(config.params, aesKey, encryptAES, encryptedRequestFields)
} else if (config.data) {
config.data = deepCrypt(config.data, aesKey, encryptAES, encryptedRequestFields)
}
config.headers['X-AES-KEY'] = encryptRSA(aesKey, import.meta.env.VITE_PUBLIC_KEY)
}
if (responseEncryption && rsaKeyPair) {
config.headers['X-RSA-PUBLIC-KEY'] = rsaKeyPair.publicKey.replace(/\r?\n/g, '\\n')
}
}
return config
},
(error) => Promise.reject(error)
)
예시는 axios request interceptor이다. 요청을 캐치해서 encryptionConfig를 확인 한 뒤, requestEncryption이 true면 AES 키를 생성하고 deepCrypt 함수를 실행한다. get 요청이라면 params를, 그 외 요청이라면 data 객체를 함수의 인자로 넘겨준다. AES키와 AES 암호화 함수, 암호화 필드 설정 객체를 함께 넘겨주면 위에서 구현한대로 deepCrypt 함수가 순회를 돌며 암호화가 필요한 필드를 암호화해준다. 만약 받을 응답에도 암호화가 적용되어야 한다면 서버가 데이터를 암호화 할 수 있도록 헤더에 RSA public key를 넣어준다.
응답도 마찬가지로 axios response interceptor를 사용해서 동일한 로직으로 작성하면 된다. 다만, 응답 헤더에서 암호화된 AES 키를 꺼내 클라이언트에서 생성한 RSA private key로 복호화해야 데이터를 복호화할 수 있다. deepCrypt 함수에는 AES 복호화 함수를 넣어주면 알아서 순회를 돌면서 복호화를 시켜준다.
이번 경험을 통해 암호화에 대해 정말 많이 배웠다. 대칭 암호화와 비대칭 암호화를 더 잘 이해할 수 있게 되었고, 사소한 부분에서도 보안에 신경써야 데이터를 안전하게 지킬 수 있다는 것도 알게 되었다. 그리고 무엇보다, 마냥 멀고 어렵게만 느껴졌던 '보안'이라는 주제가 재밌다는 것을 느낀게 가장 큰 수확이었다. 소프트웨어 엔지니어로써 성장해나가기를 바라면서 꼭 공부하고 싶었던 두 분야가 데이터와 보안이었는데, 보안 분야의 맛보기로 좋은 기회였던 것 같다. 앞으로 네트워크에 대해서 더 공부를 하면서 보안에 대해서 깊게 공부해보고싶다!