즐코

crypto 모듈을 이용한 암호화 / JWT 만들어보기 본문

NodeJS

crypto 모듈을 이용한 암호화 / JWT 만들어보기

YJLEE_KR 2022. 3. 4. 13:48

 

buffer

 

= 컴퓨터가 이해할 수 있는 binary data (이진수 데이터)의 스트림을 직접 다루기 위해 node.js에서 제공해주는 API다

= 브라우저에서 사용하는 JS에는 없다.

 

buffer가 뭔지 알기 전에 기본적인 걸 잠깐 짚고 넘어가야겠다.

 

- Character Set 문자 집합

각각의 문자를 컴퓨터가 이해할 수 있도록 숫자로 정의해놓은 규칙표 (유니코드, 아스키코드 등)

 

* charCodeAt() 메소드 

JS 메소드 중에 .charCodeAt()를 활용하면 각 문자를 유니코드표에 맞춰 숫자로 출력해준다.

문자.charCodeAt(0) 하면 문자열의 0번째인 y를 나타내는 숫자를 출력해준다.

 

 

- 문자인코딩

위에선 문자를 숫자로 변환했다면 (y->121)

이젠 컴퓨터가 정말로 알아먹을 수 있게끔 이진수 binary data로 변환해야한다. (121->이진수로 변환)

이 때 숫자를 몇 bit로 나타낼 것인가를 정하는 것 = 문자인코딩

 

우린 주로 UTF-8을 쓰는데, 문자가 바이트 단위로 인코딩 되어야한다.

즉 8개의 비트, 0과 1로된 집합을 만들어야한다.

 

숫자 121을 이진수로 바꾸면? 1111001

하지만, UTF-8에 따르면 8bit로 구성해야하니까 앞에 bit를 더 추가해서 01111001 로 컴퓨터는 저장할 것이다.

 

=> 즉, 컴퓨터는 모든 데이터를 바이너리 데이터로 저장한다는 게 포인트

 

근데, 이 스트림 stream이라는 건 뭘까?

한 지점에서 다른 지점으로 이동하는 일련의 데이터를 의미한다고 한다.

 

데이터가 이동한다는 건 뭘까?

데이터를 가지고 작업하거나 데이터를 읽는 등의 작업을 할 때 일어난다.

 

한 작업이 특정 시간동안 받는 데이터에는 최소량과 최대량이 존재하는데,

데이터 처리 시간보다 데이터 도착 시간이 더 빠르면? 데이터는 먼저 도착 데이터들이 처리되기 전까지 기다려야한다.

데이터 도착 시간보다 데이터 처리 시간이 더 빠르면? 이미 도착한 데이터는 처리할 데이터 최소량이 쌓일 때까지 기다려야함

 

이 때 데이터가 대기를 타는 영역이 buffer이다. 

즉, 스트리밍 중에 데이터가 buffer에 일시적으로 모이고, 기다리고, 처리될 때가 오면 보내지는 과정인 것이다.

 

버스정류장 예시가 아주 잘 와닿았다.

버스 정류장에서 사람이 어느정도 차야 출발하거나(최소량을 채워야함)

이미 버스가 꽉차 출발하면(최대량을 채움) 그 뒤에 오는 사람들은 버정에서 다음 버스를 기다려야한다.

이 때 이 버정이 버퍼의 역할이다.

 

좀 더 자세히 들어가자면, 버정에 도착하는 사람들의 시간을 어느 누구도 제어할수 없지만 버스 출발은 제어할 수 있다.

node.js도 마찬가지로 데이터의 도착 시간, 전송 속도를 제어할 수 없지만, 언제 데이터를 내보낼지는 결정할 수 있다. 

아직 데이터를 보낼 때가 아니면 buffer에 데이터를 넣어 놓는 것이다.

(버퍼링도 데이터가 채워지기 전까지 기다리는 과정이다)

 

이 때 우리는 이 바이너리 데이터가 buffer에 머무르는 동안 이 데이터들을 조작하고 다룰 수 있다.

Buffer.from(변환할 데이터) : 아무 옵션 인자를 넣지 않으면 기본적으로 16진수로 변환해준다.
Buffer.from(변환할 데이터, 'base64') : 64진수로 변환해준다.

 

 

 

toString() 메소드를 쓰면 기본적으로 16진수를 원래 문자열로 변환해준다. (디코딩)

 

 

Buffer.from은 기본적으로 utf-8 이라는 문자셋을 따라서 변환해준다.

문자 1 => 16진수로 31 / 문자 0 => 16진수로 30 이다.  

 

 

buffer 관련해서 아래의 블로그를 보고 정리한 내용이다.

https://tk-one.github.io/2018/08/28/nodejs-buffer/

 

Node.js의 Buffer를 제대로 이해해보자

이 글은 Daajust의 Do you want a better understanding of Buffer in Node.js? Check this out. 글을 번역 한 글입니다. 모든 저작권과 권리는 Daajust에게 있습니다. 곳곳에 의역이 들어가있는 점 양해부탁 드립니다 :) No

tk-one.github.io


암호화 - 양방향 과 단방향

 

양방향 : 암호화/복호화 둘다 가능

단방향 : 복호화가 불가능

 

node.js가 기본적으로 제공해주는 crypto라는 모듈을 가지고 암호화가 가능하다.

npm install 없이 그냥 아래와 같이 모듈을 불러오면 된다.

 

 

 

메소드 정리

 

createHash() : 사용할 알고리즘을 인자로 넣어준다. sha256, sha512 등..
update() : 암호화할 데이터를 인자로 넣어준다.

 

이렇게 하고 출력해보면? 

아래와 같이 객체형태가 나온다. 우린 여기서 이 해쉬값을 꺼내와야한다.

 

 

digest() : 인코딩 방식을 인자로 넣어준다. 어떤 인코딩 방식으로 암호화된 문자열을 표현할지 정해주는 것

 

base16이나 hex나 둘다 16진수로 변환해주는데, base16은 특이하게 buffer에 담겨져서 나온다.

base64는 64진수로 변환해주는데, 16진수보다 더 짧게 나온다.

 

 

근데, 이렇게만 암호화를 한다면 보안에 취약하다.

왜냐하면, 집요한 해커가.. 모든 암호에 대해서 어떤 해시값이 나올지 DB화 시켜놨을 수도 있기 때문이다.

이런 걸 레인보우 테이블이라고 한다고 한다.

 

이런 레인보우 테이블 방지를 위해 salt를 뿌려준다. 

비번에 특정한 salt 문자열을 붙여서 그 합친 것을 해쉬화해주는 개념

 

createHmac() :
첫번째 인자 - 암호화에 사용할 알고리즘
두번째 인자 - 암호화에 쓸 키, 소금값

 

같은 비번이여도 소금을 뿌리면 아래와 같이 아예 다른 해쉬값이 나온다.

보통 이 salt는 .env파일에 넣거나 랜덤 문자열로 생성해서 비번과 같이 DB에 저장한다고 한다.

 


랜덤 salt와 그에 따른 랜덤 key 생성해주는 함수

 

1. 랜덤 소금 만들기
randomBytes (생성할 salt 길이, 콜백함수) 

 

아래의 예제코드에선 64바이트 길이의 소금을 만들어주는데 버퍼형태로 리턴되므로,

buf.toString('base64') 를 써서 문자열 salt로 변환해준다.

 

2. 랜덤 소금가지고 랜덤 키 만들기
pbkdf2 (비번, 소금, 해쉬화반복횟수, 비번길이, 해쉬알고리즘, 콜백함수)

 

아래의 예제코드에선 위에서 랜덤으로 생성한 소금을 넣고, 930216번 반복해서 sha512 방식으로 해쉬화하여 64바이트 길이의 비번을 생성해준다. 이것도 소금과 마찬가지로 buffer형태로 리턴되므로, key.toString('base64')를 써서 문자열로 만들어서 저장해준다. 

 

 

이 때, salt와 key를 무조건 같이 저장해야한다. 왜냐하면, 랜덤으로 계속해서 소금값도 달라지고, 그에 따라 암호화 결과도 매번 달라지기 때문이다. 당연하지만 반복횟수, 비번길이, 해쉬 알고리즘, 인코딩 방식까지 다 같아야 같은 결과가 나오니 주의할 것

 

 

crypto 모듈 암호화 관련 내용은 아래의 블로그를 참고하였다.

https://www.zerocho.com/category/NodeJS/post/593a487c2ed1da0018cff95d

 

(NodeJS) crypto 모듈을 사용한 암호화

안녕하세요. 이번 시간에는 crypto 모듈을 사용해서 비밀번호를 암호화하는 방법에 대해 알아보겠습니다. 예전 패스포트 강좌에서는 패스포트 기능 설명에 중점을 두었기 때문에 비밀번호는 그

www.zerocho.com

 


JWT 를 crypto 모듈을 사용해서 만들어보자.

 

JWT 개념 + 인코딩 + 해쉬화 이것들을 잘 조합해서 JWT를 직접 만드는 걸 배웠다.

 

크게 4단계로 생각하면 된다.

 

#1. Header(토큰타입+암호화알고리즘방식) & Payload(사용자정보)  => 인코딩

#2. Signature (Header+Payload+서버가 가지고 있는 비밀키) => header에서 설정한 암호화 알고리즘으로 암호화시키기

#3. 1,2에서 만든 Header.Payload.Signature 의 형태로 쿠키에다가 담아주기

#4. 사용자 로그인 시 token 검증하기

 

 

#1. header, payload 만들기

 

 

Signature 만들기 전 header, signature 인코딩 해주기

 

객체 -> JSON문자열 -> Buffer 형태(16진수) -> 문자열 형태(64진수로 길이 줄이기) -> '='지워주기

 

이 때, 그냥 Buffer.from(header) 이렇게 해주면 될까? 아래와 같은 에러가 뜬다.

TypeError [ERR_INVALID_ARG_TYPE]: The first argument must be of type string or an instance of Buffer, ArrayBuffer, or Array or an Array-like Object. Received an instance of Object

 

왜냐하면 header와 payload 둘 다 객체형태라 buffer.from의 인자로 들어갈 수 없다.

Buffer.from의 인자로 넣기 위해 객체를 string화 시켜주려면 JSON.stringify를 써주자.  

 

 

그다음에 buffer에 넣어주자. 

 

 

그 다음 buffer 타입을 toString('base64') 써서 64진수로 변환한 문자열로 한번 더 인코딩 시켜주자

 

 

여기서 출력해보면 마지막으로 맨 끝에 =가 붙은 게 보인다. (64진법에는 = 는 없다.)

6글자 단위로 쪼개었을 때 빈 곳을 =로 채워주는 것 같다. 따라서, =를 제거해주기 위해 정규식을 활용한다. 

replace(/[=]/g,'') 

 

 

 

#2. crypto 모듈 써서 signature 만들기 (해쉬화)

 

crypto.createHmac('alg방식', 소금).update(암호화할'header.payload').digest('인코딩방식').replace(=없애주기)

 

 

 

 

#3. 1,2에서 만든 Header.Payload.Signature 의 형태로 쿠키에다가 담아주기

 

 

https://jwt.io/

내가 작성한 jwt가 유효한지 검사해주는 사이트이다. 

 

 

 

여기까지의 코드는 session과 비슷하다. 이제 jwt를 쿠키로 던지면 된다.

 

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyaWQiOiJ5amxlZTEyIiwibmFtZSI6InlqIn0.NSj0Viji6juXmwtr/i0tF2dG6BJUPyF8AcMY3afK5+M

 

 

 

#4. 사용자가 로그인 시 token이 유효한지 검사하기 

 

 

로그인 시 미들웨어에서 사용자를 검증할 때 토큰이 유효한지 안한지 체크해야한다.

즉, 사용자가 요청을 보냈을 때 요청과 함께 보낸 쿠키에 있는 내용, 즉 토큰을 검사해야한다.

 

우선, 사용자가 로그인을 했다고 가정하고 시작해보자.

 

사용자가 보낸 요청안에는 cookie가 있을 것이다.

그에 따라 req.headers.cookie를 출력해봤을 때 아래와 같이 토큰이 찍힌다고 치자.

 

 

우린 이걸 쪼개서 활용해줘야한다.

header,payload,signature가 각각 . 으로 구분되어있으므로 이걸 split시켜준다. (구조분해할당 이용)

 

 

사용자의 토큰에 있던 header와 payload를 가지고 우리 서버가 가지고 있는 salt로 signature를 만들어본다.

그걸 deSignature라고 하자.

 

 

우리가 만든 signature (deSignature)사용자의 토큰값에 있는 signature를 비교해서 TRUE 가 떨어지면 검증 완료

 

 


여기서 payload같은 값은 decoding해서 그 값을 활용할 수도 있겠다.

이땐, JSON형태로 온 값을 다시 객체화 시켜줘야하므로 JSON.parse()를 활용한다.

디코딩하는 게 살짝 헷갈렸는데, 인코딩의 반대로 간다고 생각하면 된다.

 

*인코딩

: 객체->JSON문자열->Buffer 형태(16진수)->문자열 형태(64진수로 길이 줄이기)->'='지워주기

 

*디코딩 :

pay라는 인코딩된 문자열을 다시 buffer형태로 / 이때 인코딩 된게 64진수 문자열이니까 64진수 buffer로

-> 문자열로 바꿔주기 toString()  / {"userid":"yjlee12","name":"yj"}

-> 문자열을 객체로 다시 변환  JSON.parse() / { userid: 'yjlee12', name: 'yj' }

 

Comments