HTTP(Hypertext Transfer Protocol)은 웹에서 데이터를 주고 받아 페이지를 로드하는 데에 사용되는 프로토콜이에요.
예를 들어 책상을 한번 치는 것을 ‘Yes’ 이라는 뜻으로, 책상을 두번 치는 것을 ‘No’ 라는 뜻으로 알기로 통신을 하는 나와 상대방이 약속을 했다면 그것은 프로토콜이라고 할 수 있어요.
HTTPS는 HTTP의 보안 버전으로, HTTP와 달리 전송 시 패킷을 암호화하여 중간자가 데이터를 도청하거나 진짜 서버/클라이언트로 위장하여 데이터를 바꿔치기하는 등의 공격을 방지해요. 일반 HTTP로 통신할 경우 주고 받는 데이터가 통신 과정에 그대로 노출되기 때문에 마음만 먹으면 도청하거나 바꿔치기 할 수 있어요.
HTTP는 TCP 프로토콜을 기반으로 하고 있어요. TCP의 보안 버전은 TLS인데요. HTTPS는 TCP 프로토콜로 구현된 HTTP 위에 TLS가 구현된 형태로 동작해요.
TCP 프로토콜에서는 통신을 시작할 때 3-Way Handshake 과정을 통해 서로가 통신이 준비되었다는 것을 확인해요. 서로가 통신하고 싶은 대상이 맞는지, 올바른 곳으로 잘 송수신하고 있는지를 확인해요.
TLS 프로토콜의 경우 기존 TCP 통신에 보안 방식을 더한 프로토콜이에요. TLS 통신의 경우 통신을 시작할 때 TCP 3-Way Handshake를 마친 뒤 곧바로 이어 TLS Handshake를 마치는 것이 일련의 시작 과정입니다. 서로가 암호화 통신을 시작하기 위해 준비하는 과정이죠.
이런 준비 과정에는 서로가 암호화 통신에 사용할 프로토콜 버전과 알고리즘을 합의(Negotiation)하고 암호화 키를 만드는 과정이 포함되어 있어요.
어떤 기능을 꾀해야 할까?
- 서버: 클라이언트가 내가 통신하고자 하는 클라이언트가 맞는가?
- Handshake 과정 중 이전에 통신했던 클라이언트가 현재 통신하는 클라이언트와 일치하는가?
- 클라이언트: 서버가 내가 통신하고자 하는 서버가 맞는가?
- Handshake 과정 중 이전에 통신했던 서버가 현재 통신하는 서버와 일치하는가?
- 공인된 인증서를 받은 신뢰할 수 있는 서버가 맞는가?
- 서버와 클라이언트가 매번 암호화에 사용하는 키 값은 재사용될 수 없는 유일한 값인가? (매 연결마다 달라지는가?)
어떻게 작동할까?
- 클라이언트 Hello: 클라이언트에서 랜덤 난수를 생성하여 서버에 보냅니다. 암호화 연결이 수립되지 않았기 때문에 평문으로 전달됩니다.
- 서버 Hello: 서버에서 서버 인증서와 함께 랜덤 난수를 생성하여 클라이언트에 보냅니다. 마찬가지로 암호화 연결이 수립되지 않았습니다.
- 인증서 검증: 클라이언트에서 서버로부터 수신한 서버 인증서를 인증 기관 (CA)에 유효한지 검증 요청을 합니다.
- 프리 마스터 키 전송: 클라이언트에서 프리 마스터 키 (랜덤 난수)를 생성하고 서버의 공개키로 암호화하여 보냅니다. 이전 단계에서 수신한 인증서에는 공개키가 담겨있습니다. 공개키에 대한 개인 키는 서버만 알고 있습니다.
- 세션 키 생성
서버: 암호화된 프리 마스터 키를 복호화하고 프리 마스터 키, 클라이언트 랜덤 난수, 서버 랜덤 난수를 이용하여 세션 키를 생성합니다.
클라이언트: 프리 마스터 키, 클라이언트 랜덤 난수, 서버 랜덤 난수를 이용하여 세션 키를 생성합니다.
⇒ 서버와 클라이언트는 같은 데이터로 세션 키를 생성하기에, 서로 동일한 값을 가지고 있습니다.
- 클라이언트 OK: 클라이언트에서 서버로 ‘완료’ 응답을 세션 키로 암호화하여 전송합니다.
- 서버 OK: 서버에서 클라이언트로 ‘완료’ 응답을 세션 키로 암호화하여 전송합니다.
- 안전한 양방향 암호화 달성!: Handshake가 완료되었습니다. 이후 서버와 클라이언트가 주고 받는 데이터는 모두 세션 키를 통해 암호화된 상태로 처리됩니다.
왜 이렇게 작동할까?
어떻게 작동하는 지 보다는 ‘왜 저렇게 될 수 밖에 없는가’가 더 중요합니다. 위 과정을 보고 들 수 있는 의문들을 적어봤어요.
의문 1. Client Random이랑 프리 마스터 키랑 다른 게 뭐야?
어차피 프리 마스터 키는 클라이언트에서 랜덤으로 생성된다. 심지어 암호화되어 전송된다. 클라이언트 Hello도 클라이언트에서 랜덤으로 생성되어 서버로 전송된다. 굳이 클라이언트 Hello를 보낼 필요가 있나?
클라이언트 Hello는 진짜 클라이언트가 자기 자신이라는 것을 증명하는 방법입니다. Hello가 없다면 서버는 진짜 클라이언트가 누구인지 식별할 수 없어요!
프리 마스터 키를 제공한 클라이언트와 Hello를 제공한 클라이언트가 같다는 것을 확인함으로써, 서버는 진짜 클라이언트를 신뢰하고 공격자를 판별할 수 있는 것이에요.
만약 Hello가 없다면, 프리 마스터 키가 암호화 되어있다고 한들 중간자의 도청으로부터 보호될 수 없어요. 그 암호화된 데이터를 공격자가 가로채서 클라이언트 대신 서버에 전달할 수 있어요. (굳이 데이터를 까보지 않아도 클라이언트인 척을 할 수 있는거죠!) Hello가 없어서 서버가 누가 진짜 클라이언트인지 판단할 수 없기 때문에 가능한 일이에요. 이렇게 되면 서버는 공격자를 진짜 클라이언트로 인식할 수 있습니다.
의문 2. 그렇다면 공격자가 Handshake의 첫 과정 (Hello)부터 개입한다면?
그렇다면 공격자가 진짜 클라이언트처럼 행동하여 처음부터 TLS Handshake 과정을 시작하면 서버는 클라이언트가 정상인지 확인할 수 있는 방법이 없지 않나?
서버는 클라이언트가 정상인지 비정상인지 확인할 수 없습니다. 이런 경우 공격자는 서버로부터 인증서를 받고, 그 인증서를 통해 서버의 공개키를 얻어 통신하게 돼요. 결론적으로 서버와 암호화 통신을 시작할 수 있어요.
하지만 이렇게 해서 공격자가 얻는 정보는 한정적이에요. 어차피 진짜 클라이언트와 서버의 통신은 공격자가 가지고 있는 키와 다른 키로 이루어지기 때문에 탈취할 수 있는 정보가 없습니다. 말이 개입이지 실제로는 하나의 새로운 연결을 수립하는 셈인 것이죠.
의문 3. 공격자가 인증서를 바꿔치기 한다면?
공격자가 인증서를 바꿔치기 한다면? 인증서만 있으면 공격자가 인증서를 바꿔치기 해서 중간자 공격이 가능한 것 아닌가?
그렇기 때문에 인증서는 맘대로 가질 수 없어요. 나 혼자서 발급한다고 해도 그것을 공인해주는 장치가 아무도 없기 때문에 의미가 없죠. 인증서는 인증서 발급 기관 (CA)에서 특정 도메인에 대해 발급받을 수 있어요. CA는 인증서를 발급할 때 발급자가 해당 도메인의 소유자와 동일한 지를 제일 먼저 검증해요. 클라이언트가 접속을 원하는 서버가 맞는지 확인할 수 있는 제일 기본적인 지표는 서버가 가지고 있는 인증서이기 때문이죠!
여러 구현이 있지만 Go의 기본 HTTP 라이브러리인 net/http에서 사용되는 TLS가 어떻게 구현되었는지를 살펴볼게요. Go의 기본 TLS 구현은 crypto/tls 라이브러리에서 이루어져요.
crypto/tls/handshake_client.go
func (c *Conn) clientHandshake(ctx context.Context) (err error) {
if c.config == nil {
c.config = defaultConfig()
}
hello, ecdheKey, err := c.makeClientHello() // 1. 클라이언트 Hello 만들기
if err != nil {
return err
}
... // 이미 수립된 연결이 있는지 확인
hs := &clientHandshakeState{
c: c,
ctx: ctx,
serverHello: serverHello,
hello: hello,
session: session,
}
// !Handshake 시작!
if err := hs.handshake(); err != nil {
return err
}
return nil
}
func (hs *clientHandshakeState) handshake() error {
c := hs.c
isResume, err := hs.processServerHello() // 2. 서버 Hello 처리
if err != nil {
return err
}
// Handshake 수행
if err := hs.doFullHandshake(); err != nil {
return err
}
if err := hs.establishKeys(); err != nil {
return err
}
...
func (hs *clientHandshakeState) doFullHandshake() error {
c := hs.c
msg, err := c.readHandshake(&hs.finishedHash)
if err != nil {
return err
}
// Handshake 메세지에서 서버 인증서를 받아오기
certMsg, ok := msg.(*certificateMsg)
if !ok || len(certMsg.certificates) == 0 {
c.sendAlert(alertUnexpectedMessage)
return unexpectedMessageError(certMsg, msg)
}
if c.handshakes == 0 {
// 3. 서버 인증서를 검증
if err := c.verifyServerCertificate(certMsg.certificates); err != nil {
return err
}
...
}
// 서버 Hello가 진행되었는지 확인
shd, ok := msg.(*serverHelloDoneMsg)
if !ok {
c.sendAlert(alertUnexpectedMessage)
return unexpectedMessageError(shd, msg)
}
// 4. 프리 마스터 키 생성
preMasterSecret, ckx, err := keyAgreement.generateClientKeyExchange(c.config, hs.hello, c.peerCertificates[0])
if err != nil {
c.sendAlert(alertInternalError)
return err
}
if ckx != nil {
// 5. 서버에 프리 마스터 키 전송
if _, err := hs.c.writeHandshakeRecord(ckx, &hs.finishedHash); err != nil {
return err
}
}
...
// 5. 마스터 키 (세션 키) 생성
// 프리 마스터 키, 클라이언트 Hello Random, 서버 Hello Random을 사용하여 생성한다!
hs.masterSecret = masterFromPreMasterSecret(
c.vers, hs.suite, preMasterSecret, hs.hello.random, hs.serverHello.random)
...
return nil
}
func (hs *clientHandshakeState) handshake() error {
...
if err := hs.doFullHandshake(); err != nil {
return err
} // doFullHandshake를 마친 뒤...
// 6. 클라이언트 OK 전송
if err := hs.sendFinished(c.clientFinished[:]); err != nil {
return err
}
if _, err := c.flush(); err != nil {
return err
}
c.clientFinishedIsFirst = true
if err := hs.readSessionTicket(); err != nil {
return err
}
// 7. 서버 OK 수신
if err := hs.readFinished(c.serverFinished[:]); err != nil {
return err
}
}
...
return nil
}
이렇게 간단하게 TLS Handshake가 작동하는 전반적인 흐름을 알아봤어요. 실제로는 TLS 버전이나 키 교환 알고리즘에 따라 조금씩 Handshake 과정이 다르거나, 클라이언트가 공개 키를 사전에 저장해두고 통신 시 일치 여부를 확인하여 중간자의 개입을 방지하는 TLS Pinning과 같은 과정이 추가로 있을 수 있어요. (예로 TLS 1.3에서는 프리 마스터 키가 없어요!)
자세한 내용이 궁금하다면 TLS 1.3의 Handshake 방식, 0-RTT이나, Handshake 이후 언제까지 해당 협상이 유효할 지를 찾아보면 흥미롭지 않을까 싶습니다 :>