HTTP/1.1의 변경사항은 아래와 같다.
- 통신 고속화
- Keep-Alive가 기본적으로 유효
- 파이프라이닝
- TLS에 의한 암호화 통신 지원
- 새 메서드 추가
- PUT, DELETE가 필수 메서드
- OPTION, TRACE, CONNECT 메서드 추가
- 프로토콜 업그레이드
- 이름을 사용한 가상 호스트 지원
- 크기를 사전에 알 수 없는 콘텐츠의 청크 전송 인코딩 지원
1. 통신 고속화
이전 캐시에서 설명한 ETag와 Cache-Control은 HTTP/1.1의 기능이다. 캐시는 콘텐츠 리소스마다 통신을 최적화하는 기술이지만, Keep-Alive와 파이프 라이닝은 좀 더 범용적으로 모든 HTTP 통신을 고속화하는 기능이다.
브라우저에서 서버로 동시에 접속할 때 HTTP/1.0에서 권장하는 값은 4였다. 병렬로 동시에 접속하는 이 값이 HTTP/1.1에서는 2로 내려갔는데, Keep-Alive나 파이프라이닝의 효과를 고려한 결과라고 한다. 또한, 프로토콜 버전 업으로 속도가 개선되고 서버의 부하도 내려간다.
1.1 Keep-Alive
HTTP의 아래층인 TCP/IP 통신을 효율화하는 구조로, 요청마다 통신을 닫지않고 연속된 요청에는 연결된 connection을 재사용함
Why>
HTTP는 TCP를 사용하기 때문에 3-way-handshake를 통해 연결을 성립한다. 이후 request와 적절한 response를 받으며 통신을 하고 4-way-handshake를 통해 연결을 닫는다. 하지만, 1번의 request를 보내는 것이 아닌 연속적인 request를 보내야 할 경우 요청마다 3-way, 4-way handshake를 해야한다. 빛의 속도가 아무리 빠르다고 하지만, 지구 반대편까지 통신을 보내는 데 0.2초가 걸린다고 한다. 연속적인 request를 보낼 경우 이전에 사용한 3-way-handshake를 통해 연결된 connection을 재사용한다면 효율적인 통신이 오고 갈 수 있다. 이때 사용하는 것이 Keep-Alive이다.
이 기능은 HTTP/1.0에서 지원하지 않았지만, 몇몇 브라우저에서 이미 지원하고 있었다. HTTP/1.0에서 요청 헤더에 아래 헤더를 추가함으로써 Keep-Alive를 이용할 수 있었다.
Connection: Keep-Alive
이 헤더를 받아들인 서버가 Keep-Alive를 지원하면, 같은 헤더를 응답 헤더에 추가해서 반환한다.
HTTP/1.1에서는 이 동작이 기본으로 되어 있다. TLS 통신을 이용할 경우, 특히 통신 시간을 많이 줄여준다. (인증하는 시간이 추가되지 않아서 그런 것 같다.) HTTP 아래 계층의 프로토콜인 TCP/IP도 접속할 때는 1.5회 왕복의 통신을 필요로 한다. 패킷이 1회 왕복하는 시간을 1RTT(Round-Trip Time)로 부르며, TLS에서는 서버/클라이언트가 통신을 시작하기 전에 정보를 교환하는 핸드세이크과정에서 2RTT만큼 시간이 걸린다.
Keep-Alive를 이용한 통신은 클라이언트나 서버 중 한 쪽이 아래 헤더를 부여해 접속을 끊거나 타임아웃될 때까지 연결이 유지된다.
Connection: Close
Keep-Alive 지속 시간은 클라이언트와 서버 모두 가지고 있다. 한 쪽이 TCP/IP 연결을 끊는 순간에 통신은 완료되므로, 어느 쪽이든 짧은 쪽이 사용된다. Chrome 300초, firefox 115초, edge 120초, safari 60초로 기본 타임아웃 시간을 가지고 있다.
통신이 지속되는 동안 os의 자원을 계속 소비하므로, 실제로 통신이 전혀 이루어지지 않는데 접속을 유지하는 것은 바람직하지 않다. 짧은 시간에 접속을 끊는 것에 의미가 있다.
1.2. 파이프라이닝
최초의 요청이 완료되기 전에 다음 요청을 보내는 기술. 다음 요청까지의 대기 시간을 없앰으로써, 네트워크 가동률을 높이고 성능을 향상 시킨다. Keep-Alive 이용을 전제로, 서버는 요청이 들어온 순서대로 응답을 반환
그대로 동작한다면, 왕복 시간이 걸리는 모바일 통신에서 큰 효과를 기대할 수 있다. 하지만, 실제로는 파이프라이닝 기능을 구현하지 않았거나 구현했어도 기본 설정에서 꺼둔 브라우저도 있다. 크롬 또한 버전 18에서 지원했지만, 버전 26에선 파이프라이닝을 삭제했다. 현재 기본으로 활성화한 브라우저는 오페라와 iOS 5 이후의 사파리 정도이다.
호환성, 보안, 성능 이슈 등 파이프라이닝의 이점이 크지 않았다는 것이 이유인 것 같다. 최근에는 HTTP/2와 같은 새로운 프로토콜이 등장하며 파이프라이닝의 필요성이 더 낮아졌다고 한다.
NOTE_스트림
그렇다고 파이프라이닝이 쓸모없는 사양이었다는 것은 아니다. 파이프라이닝은 여러 문제를 해결하고, HTTP/2에서 스트림이라는 새로운 구조로 다시 나왔다.
- HTTP/2 에서는 HTTPS 통신이 전제가 된다. HTTPS 이므로 기본적으로 프록시가 송수신되는 데이터 내부를 볼 수 없다. 프록시는 통신을 중계만 하게 됐으므로 최신 프로토콜을 해석할 수 없는 프록시가 도중에 방해할 일이 사라졌다.
- 통신 순서를 유지해야 한다는 제약이 HTTP/2에서는 사라졌다. (응답 순서가 파이프라이닝의 성능 이슈를 만들었었음.) HTTP/2에서는 하나의 세션 안에 복수의 스트림이 공존한다. 시분할로 각 스트림의 통신이 이루어지므로, 서버 측의 준비가 된 순서로 응답을 반환할 수 있다. 또한, 우선순위를 부여해 순서를 바꾸는 것도 가능하다.
2. 전송 계층 보안(TLS)
컴퓨터 네트워크에서 보안 통신을 제공하는 프로토콜로 기밀성과 데이터 무결성을 보장하여 민감한 정보의 안전한 전송을 도와준다.
HTTP/1.1과 함께 이용되는 것을 강하게 의식해서 만들어졌지만, TLS 암호화 자체는 HTTP 뿐만 아니라 다양한 형식의 데이터를 양방향으로 흘려보낼 수 있다. TLS는 기존 프로토콜에 통신 경로의 안정성을 추가해 새로운 프로토콜을 만들어낼 수 있는 범용적인 구조로 되어 있다. HTTP는 80번 port를 사용하고, HTTPS는 443번을 사용해 다른 서비스로 취급된다. HTTPS 이외에 메일 전송 프로토콜 SMTP(25번)의 TLS 버전인 SMTPS(465번) 등 기존 프로토콜의 버전업에도 이용된다.
HTTP/1.0과 1.1에서는 프록시 서버 등이 통신을 해석해 캐시함으로써 고속화 기능을 제공할 수 있었지만, 자신이 해석할 수 없는 프로토콜을 멈춰버리는 경우가 있었다. TLS를 사용하면 조작할 수 없는 안정된 통신로가 생기므로, HTML5에서 새로 도입된 웹소켓 같은 통신 프로토콜이나 HTTP/2 등 이전과 상위 호환성이 없는 수많은 새로운 시스템을 원만하게 도입하는 인프라가 됐다.
TLS는 몇 가지 버전이 있다. SSL 이라고 불리던 시절도 있었으며, 현재에도 TLS 부분을 많은 라이브러리로서 높은 점유율을 자랑하는 소프트웨어 이름이 OpenSSL이라는 등 TLS와 관련된 이름에 SSL이라는 글자가 들어간 경우가 있다. 다만 실제 SSL은 여러가지 취약성이 알려져 있어, RFC에서도 권장하지 않는다. 인터넷 서비스에서도 무효화된 것이 많고, 실제로 사용되는 것은 대부분 TLS 이다.
실제 통신 내용은 암호화된 이진 데이터이므로 내부를 보는 것은 어렵지만, curl 커맨드로 액세스할 때는 URL을 https://로만 하면 된다. 아래 소개한 옵션으로 세밀하게 동작을 설정할 수 있다. 서버 설정이 맞는지, 각 버전에서 예상한 대로 동작하는지 확인하는 데 편리하다.
- -1, --tlsv1
- TLS로 접속
- --tlsv1.0, --tlsv1.1, --tlsv1.2
- TLS 니고에이션 시 지정한 버전으로 연결하도록 강제
- --cert-status
- 인증서를 확인
- -k, --insecure
- 자가 서명 인증서라도 오류가 되지 않는다.
--cert-status와 -v를 붙여 실행하면, 인증서 상태를 아래처럼 표시한다.
$ curl --cert-status -v https://example.com
(생략)
* SSL certificate status: good (0)
2.1 해시 함수
입력 데이터를 규칙에 따라 집약해감으로써 해시 값으로 불리는 짧은 데이터를 만들어 내는 함수
해시는 '잘게 저민 조각'이라는 뜻으로, 해시드 포테이토나 해시드 비프의 해시와 같은 말이다.
해시 함수에는 암호화 통신을 사용하는 데 편리한 수학적 특성이 있다. 해시 함수를 h(). 입력 데이터를 A,B...., 산출된 해시 값을 X, Y ...라고 하자. 길이는 len() 이라고 하자.
- 같은 알고리즘과 같은 입력 데이터라면, 결과로서 생성되는 값은 같다. h(A)=X가 항상 성립한다.
- 해시 값은 알고리즘이 같으면 길이가 고정된다. SHA-256 알고리즘에선 256비트(32바이트)다. 따라서 입력 데이터가 너무 작을 경우 해시 값이 더 커지지만, 기본적으로는 len(X)<len(A)가 된다.
- 해시 값에서 원래 데이터를 유추하기 어렵다. h(A)=X의 X에서 A를 찾기 힘들다.
- 같은 해시 값을 생성하는 다른 두 개의 데이터를 찾기 어렵다. h(A)=h(B)가 되는 임의의 데이터 A,B를 찾기가 힘들다.
해시 함수는 다양한 용도로 쓰인다. 예를 들어, 다운로드한 파일이 깨지지 않았는지 확인하는 방법으로 이용된다. 1바이트라도 데이터에 차이가 있으면 해시 값이 바뀌기 때문이다. 이런 용도로 사용될 때 해시 값을 checkSum 혹은 핑거프린트라고 불리기도 한다. 또한, Git에서는 파일을 관리할 때 파일명이 아니라 파일 내용을 바탕으로 한 해시 값을 사용하고, 이 해시 값을 키로 해서 데이터베이스에 파일을 저장한다. 같은 내용의 파일이 여러 개 있을 때, 데이터의 실체는 하나다. 커다란 데이터 파일을 모두 비교하지 않고 해시 값만 비교함으로써 재빨리 같은 파일인지 판정할 수 있다. 해시 값 충돌은 매우 드물게 일어나지만, 데이터양이 적으면 거의 일어날 수 없다.
유명한 해시 함수로는 MD5(128bit), SHA-1(160bit), SHA-2(SHA-224, 256, 384, 512, 512/224, 512/256) 등 이있다. SHA-1은 서버 인증서 서명에 사용하는 것을 추천하지 않는 등 다양한 이슈가 있다.
해시 값이 어떤지 시험하는 데는 커맨드라인 툴을 사용하면 좋다. 보안 용도로 부적당한 MD5도 체크섬 용도로는 여전히 이요되고 있으며, 각 OS에 해당되는 유틸리티가 준비되어 있다. mac OS나 BSD 계열 OS에서는 md5 커맨드가 있고, 리눅스에서는 md5sum 커맨드가 있다. 파일을 1바이트만 수정해도 해시 값이 크게 달라지는 것을 알 수 있다.
2.2 공통 키 암호와 공개 키 암호 그리고 디지털 서명
Why>
암호화는 비밀스러운 방법으로 문장을 해독할 수 없는 형식으로 바꿔(인코딩) 보내고, 받아보는 쪽에서 원래 문장으로 복원(디코딩)한다. 하지만, 인코딩/디코딩하는 변환 알고리즘을 알게된다면 모든 통신이 그대로 노출된다. 보다 안전한 통신 방법으로 공동키, 공개키가 만들어졌다.
암호화에서 중요한 것은 변환 알고리즘을 비밀로 하는 것이 아닌, 알고리즘이 알려져도 안전하게 통신할 수 있는 것이다. 현재 일반적으로 사용하는 방식은 암호화 알고리즘은 공개하고, 그 암호화에 사용하는 데이터(키)를 따로 준비하는 방식이다. TLS에서 사용되는 방식으로는 공통 키와 공개 키 방식 두 종류가 있다.
공통 키 방식은 자물쇠를 잠글 때와 열 때 모두 같은 열쇠를 사용하는 방식이다. 따라서 통신하는 사람끼리는 이 번호를 공유할 필요가 있다. 공통 키 방식은 대칭 암호라고도 불린다. TLS에서는 일반 통신의 암호화에 사용한다.
공개 키 방식은 비대칭 암호라고도 불린다. 공개 키 방식에서 필요한 것은 공개 키와 비밀 키이다. 공개 키는 이름 그대로 전 세계에 공개해도 문제 없지만, 비밀 키는 다른 사람에게 알려져선 안 된다. 공개 키 방식에선 집 열쇠와 달리 암호화 키와 암호해독 키가 따로 있다. 암호화하는 것이 공개 키고 해독하는 것은 비밀키다. 사물에 비유하면 공개 키는 자물쇠, 비밀 키는 열쇠이다.
디지털 서명은 공개 키 방식을 응용한 예이다. 거꾸로 열쇠를 나누어주고 자물쇠를 비밀로 해두는 것과 같은 이미지이다. 편지 본문에 자물쇠로 잠근 데이터도 함께 첨부해 보낸다. 받는 사람이 공개된 키를 사용해 자물쇠를 열었을 때 본문과 동일한 것이 나오면 메시지가 조작되지 않은 것을 알 수 있다. 실제 디지털 서명은 본문 자체를 암호화하는 것이 아닌 해시화하고 그 결과를 암호화한다.
암호화는 완벽하지 않다. 자물쇠에 비유하면 암호의 안정성은 알고리즘과 비트 수로 강도가 정해진다. 시간을 들여 열쇠의 울퉁불퉁한 패턴을 반복해서 테스트하면 진짜 열쇠를 만들어낼 수도 있다. 실제로는 상당한 계산량이 필요하지만, 현실적인 시간 내에서 해석이 가능한 경우 보안에 취약하다고 간주된다. 이미 몇몇 알고리즘은 CPU의 발달과 함께 권장하지 않게 되었다.
2.3 키 교환
키 교환은 클라이언트와 서버 사이에 키를 교환하는 것이다. 간단한 방법으로는 클라이언트에서 공통 키를 생성한 다음 전술한 서버 인증서의 공개 키로 암호화해 보내는 방법이 있고, 키 교환 전용 알고리즘도 있다. 책에서는 RFC 2631에 정의된 디피-헬먼(DH) 키 교환 알고리즘을 소개한다. 실제로는 해당 알고리즘에서 파생된 일시 디피-헬먼(DHE)를 사용한다.
알고리즘의 핵심은 키 자체를 교환하는게 아니라, 클라이언트와 서버에서 각각 키 재료를 만들어 서로 교환하고 각자 계산해서 같은 키를 얻는 것이다.
TLS 상에서 서버가 계산에 사용할 값 p, g를 준비한다. 이 값들은 공개 정보로서 그대로 클라이언트어 넘겨줍니다. g는 법 p에 대한 원시근이라고 한다.
원시근?
g^1, g^2 ..., g^(p-2)의 어느 수치도 q로 나눈 나머지가 1이 아니다
속도를 높이고자 p와 g의 조합을 미리 계산해서 TLS 라이브러리 내부에 목록으로 만들어두는 경우가 많다고 함.
또, 한 가지 값을 계산한다. 이 값을 Y라고 부르는데, 비밀이라고한다.(서버 내부에만 저장되는 값인 듯 하다.) 클라이언트에 보내는 Ys 값은 아래와 같이 계산한다.
// g=5, p=23, Y=6
Ys = (g^Y) % p = (5^6)%23 = 23
Server Key Exchange(서버 키 교환) 메시지의 인수로서 p, g, Ys를 클라이언트로 보낸다. 전송이 끝난 후 상태는 아래와 같다.
클라이언트도 랜덤하게 값 X를 생성하고 Xs를 계산한다. X를 15라고 가정하면 아래와 같다.
Xs = (g^X)%p = (5^15)%23 = 19
Client key Exchange(클라이언트 키 교환) 메시지의 인수로서 Xs를 서버로 보낸다. 전송이 끝난 후 상태는 아래와 같다.
클라이언트가 직접 생성한 값 X와 서버가 보내준 Ys로 공통 키의 시드를 생성한다.
ZZ = (Ys^X)%p = (8^15)%23 = 2
서버도 직접 생성한 값 Y와 클라이언트가 보내준 값 Xs로 공통 키의 시드를 생성한다.
ZZ = (Xs^Y)%p = (19^6)%23 = 2
ZZ는 p로 나머지를 구하기 때문에 p를 넘어가지 않는다. 현재는 예시로 5비트 안에 들어가지만, 실제로는 1024비트, 2048비트와 같은 큰 길이를 사용한다. 이유는 생성되는 키가 작으면 취약성으로 이어지기 때문이다. 실제로 이 성질을 이용해, 생성되는 키의 비트 수를 작게 해서 보안 강도를 약화하는 로그 잼이라는 공격이 있다고 한다. 현재는 2048 bit를 권장한다고 함.
2.4 공통 키 방식과 공개 키 방식을 구분해서 사용하는 이유
공개 키 방식이 복잡한만큼 안정성이 공통 키 방식에 비해 안정성이 높다. 안정성이 높은 방식을 계속 사용하면 안정적이지만 TLS는 양쪽 방식으 조합했다. TLS에서는 통신마다 한 번만 사용되는 공통 키를 만들어내고, 공개 키 방식을 사용해 통신 상대에게 신중히 키를 전달한 이후는 공통 키를 통해 고속으로 암호화하는 2단계 방식을 이용한다. 이유는 공개 키 방식이 안정성이 높지만, 키를 가지고 있어도 암호화와 복호화에 필요한 계산량이 공통 키 방식보다 너무 많기 때문이다.
"계산량이 많지만, 안전하게 좋다" 라는 사람도 있을 것이다. 한 번 두개의 키 방식의 성능을 측정해보자.
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"crypto/rand"
"crypto/rsa"
"io"
"testing"
)
func prepareRSA() (sourceData, label []byte, privateKey *rsa.PrivateKey) {
sourceData = make([]byte, 128)
label = []byte("")
io.ReadFull(rand.Reader, sourceData)
privateKey, _ = rsa.GenerateKey(rand.Reader, 2048)
return
}
func BenchmarkRSAEncryption(b *testing.B) {
sourceData, label, privateKey := prepareRSA()
publicKey := &privateKey.PublicKey
md5hash := md5.New()
b.ResetTimer()
for i := 0; i < b.N; i++ {
rsa.EncryptOAEP(md5hash, rand.Reader, publicKey, sourceData, label)
}
}
func BenchmarkRSADecrytion(b *testing.B) {
sourceData, label, privateKey := prepareRSA()
publicKey := &privateKey.PublicKey
md5hash := md5.New()
encrypted, _ := rsa.EncryptOAEP(md5hash, rand.Reader, publicKey, sourceData, label)
b.ResetTimer()
for i := 0; i < b.N; i++ {
rsa.DecryptOAEP(md5hash, rand.Reader, privateKey, encrypted, label)
}
}
func prepareAES() (sourceData, nonce []byte, gcm cipher.AEAD) {
sourceData = make([]byte, 128)
io.ReadFull(rand.Reader, sourceData)
key := make([]byte, 32)
io.ReadFull(rand.Reader, key)
nonce = make([]byte, 12)
io.ReadFull(rand.Reader, nonce)
block, _ := aes.NewCipher(key)
gcm, _ = cipher.NewGCM(block)
return
}
func BenchmarkAESEncryption(b *testing.B) {
sourceData, nonce, gcm := prepareAES()
b.ResetTimer()
for i := 0; i < b.N; i++ {
gcm.Seal(nil, nonce, sourceData, nil)
}
}
func BenchmarkASEDecryption(b *testing.B) {
sourceData, nonce, gcm := prepareAES()
encrypted := gcm.Seal(nil, nonce, sourceData, nil)
b.ResetTimer()
for i := 0; i < b.N; i++ {
gcm.Open(nil, nonce, encrypted, nil)
}
}
공통 키는 AES, 공개 키는 RSA를 사용했고, 테스트를 하려면 cmd 창에서 아래 명령어를 입력하면 된다.
$ go test -bench .
결과는 아래와 같다.
128바이트 데이터를 암호화하고 복호화할 때 걸리는 처리 시간은 위의 결과 그댈외다. 암호화와 복호화가 다른 컴퓨터에서 진행되는 점을 감안하면 실제 산출량은 더 느린 수치가 될 수 있다. 공개 키 암호에서는 비교적 성능이 좋은 컴퓨터를 사용해도 PHS 회선 정도의 속도밖에 안나온다고 한다. AES로는 기가비트 광회선에서도 병목이 일어나지 않는 속도로 그 차이는 1만 5천배이다. AES는 Go 언어의 64비트 인텔 아키텍처용 구현으로 하드웨어 처리가 이루어져, 3~10배의 속도가 되는 것을 빼도 상당한 속도 차이가 있다.
2.5 TLS 통신 절차
TLS 통신은 크게 3개로 나눌 수 있다.
- handshake를 통해 통신 확립
- 레코드 프로토콜로 불리는 통신 단계
- SessionTicket 구조를 이용한 재접속 시의 고속 handshake
서버의 신뢰성 확인
서버의 신뢰성을 보증하는 구조는 공개 키를 보증하는 구조이기도 해서, 공개 키 기반구조(PKI)라고 불립니다. 브라우저는 서버에서 그 서버의 SSL 서버 인증서를 가져오는 것부터 시작합니다.
인증서는 X.509 형식으로 기술된 파일입니다. 이 인증서에는 사이트 주체, 발행자, 소유자 서버의 공개 키, 유효 기한 등의 항목이 있습니다. 발행자는 인증기관(CA)이라고도 불립니다.
외부에 공개된 서비스라면 인증서는 누구나 취득할 수 있습니다. openssl 커맨드로 구글의 인증서를 가져와 내용을 표시해봅시다.
$ openssl x509 -in google.crt -noout -text
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
90:76:89:18:e9:33:93:a0
Signature Algorithm: sha256WithRSAEncryption
Issuer: OU=No SNI provided; please fix your client., CN=invalid2.invalid
Validity
Not Before: Jan 1 00:00:00 2015 GMT
Not After : Jan 1 00:00:00 2030 GMT
Subject: OU=No SNI provided; please fix your client., CN=invalid2.invalid
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
RSA Public-Key: (2048 bit)
Modulus:
00:cd:62:4f:e5:c3:13:84:98:0c:05:e4:ef:44:a2:
a5:ec:de:99:71:90:1b:28:35:40:b4:d0:4d:9d:18:
...
위는 실제 서비스의 인증서입니다.
키 교환과 통신 시작
공개 키 암호를 사용하는 방법과 키 교환 전용 알고리즘을 사용하는 방법이 있다. 어느 쪽을 쓸 것인지는 최초의 Client Hello, Server Hello 니고시에이션에서 결정된다.
클라이언트는 먼저 난수를 사용해 통신용 공통 키를 만든다. 난수도 패턴이 쉽게 읽히는 알고리즘으로는 아무리 암호해봐야 애초에 생성될 공통 키가 예측되거나 암호를 결정하는 알고리즘의 중간 경과가 추측될 우려가 있다.
공개 키를 사용하는 방법은 간단하다. 서버 인증서에 첨부된 공개 키로 통신용 공통 키를 암호화해 그 키를 서버에 보낸다. 서버는 인증서의 공개 키에 대응하는 비밀 키를 갖고 있으므로 건네받은 데이터를 복호화해 공통 키를 꺼낼 수 있다.
순방향 비밀성이 우수하므로, 키 교환에서 주류가 되는 것은 키 교환 전용 알고리즘 방식이다.
통신
통신을 할 때도 기밀성과 무결성을 위해 암호화를 한다. 암호화에는 클라이언트와 서버 모두 가지고 있는 공통 키 암호 방식 알고리즘 이용한다.
TLS 1.2 이전 버전에서는 통신 내용의 해시 값을 계산한 다음, 공통 키 암호로 암호화 하는 방법을 지원했다. 하지만, 이 기법에 대한 공격이 발견됐으므로 TLS 1.3 이후에는 AES+GCM, AES+CCM, ChaCha20-Poly1305 등의 인증 암호(AEAD)로 제한될 예정이다.
통신의 고속화
지금까지 설명한 절차는 가장 긴 경우인 신규 접속의 흐름이다. 일반적인 접속에서는 우선 HTTP로 연결하기 전 TCP/IP 단계에서 1.5RTT가 걸린다. 그 후 TLS 핸드세이크에서 2RTT, 그리고 HTTP의 요청에서 1RTT의 통신 시간이 걸린다. 단 TCP/IP 통신 마지막의 0.5RTT와 그 후 TLS의 최초 통신은 함께 이루어지므로 합계는 4RTT이다. TLS를 사용하지 않는다면 2RTT로 종료된다.
4RTT라는 단위는 통신에서 전기 신호가 서버에 도달하고 응답이 되돌아오기까지 매우 긴 시간이다. 그래서 인터넷을 더 빠르게 하려면 왕복 시간을 줄이는 것이 중요하다. TLS와 HTTP에는 이를 위한 장치가 몇 가지 구현되어 있다.
우선 이 장 처음에 소개한 Keep-Alive입니다. Keep-Alive를 이용하면 세션이 지속되므로, 최초 요청 이후의 통신에서는 RTT가 1이 된다.
TLS 1.2에는 세션 재개 기능이 있어 최초의 핸드셰이크에서 전에 사용하던 세션ID(32bit)를 보내면 이후의 키 교환이 생략되므로 1RTT로 세션이 재개된다. 1.3에서는 사전에 키를 공유해 둠으로써 0RTT로 최초 요청부터 정보를 전송할 수 있게 된다.
TLS 1.3에서는 키 교환과 비밀 키 암호가 분리되어 암호화 스위트로 비밀 키 암호를 니고시에이션한 결과를 기다리지 않고, 최초의 Client Hello로 클라이언트 쪽에서 키를 교환할 수 있게 됩니다. 통신이 1왕복 줄어 1RTT로 인증이 완료된다.
TLS 아래 계층을 핸드셰이크가 필요한 세션형 TCP에서 재전송도 및 흐름 제어도 하지 않는 간이 데이터그램형 UDP로 대체해, 애플리케이션 계층에서 재전송하는 QUIC(Quic UDP Internet Connections)라는 통신 방식의 RFC화가 IETF에 제안됐다. 이미 구글의 서버가 QUIC을 지원하고, 크롬 브라우저에서도 이용된다. HTTP나 TLS 통신 이전에 전송 계층인 TCP 시점에서 핸드셰이크에 1RTT를 소비했지만, UDP는 핸드셰이크를 하지 않으므로 0RTT로 연결 할 수 있다. 현재 구현된 QUIC은 TLS에 해당하는 것을 자신이 갖는 등 거대해졌지만, 앞으로는 TLS 1.3으로 대체된다.
2.8 프로토콜 선택
TLS가 제공하는 기능 중 차세대 통신에 없어선 안 될 것이 애플리케이션 계층 프로토콜을 선택하는 확장 기능이다.
처음에 구글이 NPN(Next Protocol negotiation) 확장을 제안해서 RFC화를 목표로 초안이 만들어졌다. 그러나 니고시에이션 흐름이 크게 달라져버렸고, 다른 방식인 ALPN(Applcation-Layer Protocol Negotiation) 확장 방식이 선택돼 RFC 7301이 됐다.
ALPN에서는 TLS의 최초 핸드셰이크 시 (Client Hello) 클라이언트가 서버에 '클라이언트가 이용할 수 있는 프로토콜 목록'을 첨부해서 보낸다. 서버는 그에 대한 응답 (Server Hello)으로 키 교환을 하고 인증서와 함께 선택한 프로토콜을 보낸다. 클라이언트가 보낸 목록에서 서버가 사용할 프로토콜을 하나 골라 반환하는 방법은 콘텐트 니고시에이션과 같다.
선택할 수 있는 프로토콜 목록은 IANA에서 관리한다. 현재 등록된 이름은 아래와 같다. 주로 HTTP 계열과 WebRTC 계열 프로토콜이 있다.
2.9 TLS가 지키는 것
TLS 1.3의 인증된 암호 모드 알고리즘은 통신 내부가 보이지 않게 하고, 조작도 사칭도 되지 않도록 보호한다. 여기서 중요한 것은 공통 키의 안전한 교환이다. 그렇기때문에 DHE, ECDHE 같은 키 교환 알고리즘을 이용하여 키를 찾기 힘들도록 한다. 다만 이 방법은 도중에 통신 내용을 바꿀 수 있는 중간자 공격에 약하기 때문에, 인증서 인증을 함께 사용하여 조작 위험성을 줄인다.
3.0 PUT 메서드와 DELETE 메서드와 표준화
HTTP/1.0에서 옵션이었던 PUT과 DELETE 메서드도 HTTP/1.1에서는 필수 메서드로 추가됐다. 이로써 DB에서 데이터를 다룰 때 사용하는 기본적인 CRUD가 갖추어져, HTTP는 데이터를 취급하는 프로토콜로도 이용할 수 있게 됐다.
데이터베이스의 경우 트랜잭션이라는 큰 테두리 안에서 데이터의 불일치가 일어나지 않도록 CRUD를 사용한 1회 액션으로 데이터를 갱신한다. HTTP에는 트랜재개션이 없고, 1회 액션에 해당하는 조작이 HTTP의 1 request이다.
4.0 OPTIONS, TRACE, CONNECT 메서드 추가
HTTP/1.1에서는 OPTIONS, TRACE, CONNECT라는 새로운 메서드가 추가됐다. 이 중에서 CONNECT가 가장 자주 사용되는 새 메서드다.
4.1 OPTIONS
서버가 받아들일 수 있는 메서드 목록을 반환
command 창에서 curl을 이용해서 불러보자. google에 테스트를 해봤고 아래 결과를 받았다.
$ curl -X OPTIONS -v https://www.google.com
....
>
< HTTP/2 405
< allow: GET, HEAD
< date: Thu, 22 Feb 2024 11:05:58 GMT
< content-type: text/html; charset=UTF-8
allow 헤더에 GET, HEAD만 받아들일 수 있다고 응답을 보낸 것을 확인할 수 있다. 참고로 405 Method Not Allowed를 반환했는데, google에서 OPTIONS라는 메서드는 받아들일 수 없기때문에 405를 반환한 것이다. 추가적으로 네이버는 404 Not Found를 반환한다. OPTIONS 헤더로 요청이 오면 404 Not Found를 반환하도록 되어있는 것 같다.
4.3 CONNECT
HTTP 프로토콜상에 다른 프로토콜의 패킷을 흘릴 수 있게 한다. 프록시 서버를 거쳐, 대상 서버에 접속하는 것을 목적으로 한다.
주로 https 통신을 중계하는 용도로 사용된다. (왜?) Squid의 CONNECT 설정에 관한 웹 문서를 보더라도 https 이외의 CONNECT 접속을 거부한다라는 설정을 소개한 페이지가 대부분이다.
CONNECT 메서드를 이용하고 싶은 클라이언트는 다음과 같은 내용을 프록시 서버에 전송한다.
CONNECT example.con:8889 HTTP 1.1
CONNECT 메서드를 무조건 받아들이는 프록시는 아무 프로토콜이나 통과시켜버리므로, 맬웨어가 메일을 보내거나 하는 통신 경로로 사용될 위험이 있다.
실제 프록시 서버인 squid를 사용해, 외부 사이트에 연결해보자. 로컬 3128 포트에서 squid를 시작한다.
$ docker run -d -p 3128:3128 --name squid poklet/squid
다음으로 squid를 프록시로 사용해 외부 https 서버에 접속해보자.
$ curl -x http://localhost:3128 -v https://yahoo.com
* Trying 127.0.0.1:3128...
* Connected to (nil) (127.0.0.1) port 3128 (#0)
* allocate connect buffer
* Establish HTTP proxy tunnel to yahoo.com:443
> CONNECT yahoo.com:443 HTTP/1.1
> Host: yahoo.com:443
> User-Agent: curl/7.86.0
> Proxy-Connection: Keep-Alive
>
< HTTP/1.0 200 Connection established
<
* Proxy replied 200 to CONNECT request
* CONNECT phase completed
* ALPN: offers h2
* ALPN: offers http/1.1
* CAfile: /etc/ssl/cert.pem
* CApath: none
* (304) (OUT), TLS handshake, Client hello (1):
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-AES128-GCM-SHA256
* ALPN: server accepted h2
* Server certificate:
* subject: C=US; ST=California; L=Sunnyvale; O=Oath Holdings Inc.; CN=yahoo.com
* start date: Feb 20 00:00:00 2024 GMT
* expire date: Aug 14 23:59:59 2024 GMT
* subjectAltName: host "yahoo.com" matched cert's "yahoo.com"
* issuer: C=US; O=DigiCert Inc; OU=www.digicert.com; CN=DigiCert SHA2 High Assurance Server CA
* SSL certificate verify ok.
* Using HTTP2, server supports multiplexing
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* h2h3 [:method: GET]
* h2h3 [:path: /]
* h2h3 [:scheme: https]
* h2h3 [:authority: yahoo.com]
* h2h3 [user-agent: curl/7.86.0]
* h2h3 [accept: */*]
* Using Stream ID: 1 (easy handle 0x7fe53500d000)
> GET / HTTP/2
> Host: yahoo.com
> user-agent: curl/7.86.0
> accept: */*
>
< HTTP/2 301
< date: Thu, 22 Feb 2024 15:53:06 GMT
< strict-transport-security: max-age=31536000
< server: ATS
< cache-control: no-store, no-cache
< content-type: text/html
< content-language: en
< x-frame-options: SAMEORIGIN
< referrer-policy: no-referrer-when-downgrade
< x-content-type-options: nosniff
< x-xss-protection: 1; mode=block
< location: https://www.yahoo.com/
< content-length: 8
<
* Connection #0 to host (nil) left intact
먼저 로컬 호스트의 3128 포트에 접속했지만, CONNECT 메서드로 yahoo.com의 https용 포트인 443 포트에 연결하러 가고 있는 것을 볼 수 있다. HTTP/1.0 200 Connection established를 반환한 것은 프록시 서버이다. 실제 yahoo.com 서버는 www.yahoo.com으로 연결되길 원하므로, 301 Redirect를 반환했다. 이쪽은 프록시가 아닌 프록시 끝의 서버가 반환하는 내용이다.
확인을 다 했으면, 아래 명령어로 Squid 프록시 서버를 멈춰주자.
$ docker stop squid
5.0 프로토콜 업그레이드
HTTP/1.1 부터는 HTTP 이외의 프로토콜로 업그레이드가 가능하다. HTTP/1.0과 HTTP/1.1은 text 기반의 알기 쉬운 프로토콜이지만, 이 기능을 사용해 이진 프로토콜로 교체할 수 있다. 업그레이드는 클라이언트, 서버 측 모두 요청할 수 있다.
- HTTP에서 TLS를 사용한 안전한 통신으로 업그레이드 (TLS/1.0, TLS/1.1, TLS/1.2)
- HTTP에서 웹소켓을 사용한 양방향 통신으로 업그레이드(websocket)
- HTTP에서 HTTP/2로 업그레이드(h2c)
HTTP에서 TLS로의 업그레이드는 RFC 2817에 설명되어 있다. 다만 이 방법으로 업그레이드해도 보안이 지켜지지 않는 문제가 있다. 현재는 모든 통신이 TLS화 되고 있으며, TLS 자체가 갖는 핸드세이크 시 프로토콜 선택 기능(ALPN)을 사용하도록 권장하고 있다. HTTP/2에서는 프로토콜 업그레이드 기능이 삭제 됐다.
HTTP/2 통신도 TLS를 전제로 하고, TLS의 ALPN 사용을 권장한다. 현재 프로토콜 업그레이드는 거의 웹소켓용 이다.
요청 하는 방법은 167page 참고. 보통 클라이언트, 서버 측 모두 Upgrade와 Connection 헤더를 포함해서 요청한다.
6.0 가상 호스트 지원
HTTP/1.0은 한 대의 웹 서버로 하나의 도메인만 다루는 것이 전제였다. 하지만 웹사이트마다 서버를 따로 준비하는 것은 매우 힘든 일이다. 그렇기때문에 하나의 웹 서버로 여러 웹 서비스를 운영하는 방법이 HTTP/1.1에서 지원되기 시작했다.
http://example.com/hello라는 url에 접속하고 싶다고 가정해보자. example.com 부분을 꺼내서 도메인 네임 서버에 문의하면 도메인을 갖는 서버의 IP 주소를 알 수 있다. 다음에 http 부분 또는 도메인 이름 뒤에 포트 번호(8080)를 정해보자. HTTP/1.0까지는 실제의 서버가 받는 정보는 마지막 경로인 /hello 뿐이었다.
HTTP/1.1에서는 클라이언트가 Host 헤더에 요청을 보내고자 하는 서버 이름을 기술할 의무가 생겼다. curl 커맨드도 아무런 설정을 하지 않아도 이 헤더를 부여한다. 같은 서버 같은 포트로 tokyo.example.com과 osaka.example.com이라는 두 개의 서비스가 호스트되고 있다고 가정하자. 요청 헤더의 Host 헤더를 보면, 서버는 어떤 서비스를 요청하는지를 판정할 수 있다.
아파치 웹 서버를 사용하면 호스트 이름에 따라서 해당하는 서비스의 콘텐츠를 가져와 반환할 수 있다.
NameVirtualHost *:80
<VirtualHost *:80>
ServerName tokyo.example.com
DocumentRoot /www/tokyo
</VirtualHost>
<VirtualHost *: 80>
ServerName osaka.example.com
DocumentRoot /www/osaka
</VirtualHost>
클라이언트에서는 Host를 붙이는 것 뿐이지만, 서버에서는 그 정보를 바탕으로 같은 서버에서 콘텐츠를 구분해 보낼 수 있게 된다.
7.0 청크
HTTP/1.1에서 지원되는 새로운 데이터 표현으로, 전체를 한꺼번에 전송하지 않고 작게 나눠 전송하는 청크방식이 있다. 청크를 사용하면 시간이 오래 걸리는 데이터 전송을 조금씩 앞당겨 시행할 수 있다. 청크 방식을 스트리밍 다운로드/업로드라고 부르는 경우도 있다.
예를 들면 라이브 동영상을 배포하거나 시간이 걸리는 검색 결과를 전송할 때, 동영상의 앞부분부터 혹은 검색 엔진이 찾아낸 순서대로 반환할 수 있다. 클라이언트 측에서 처리할 때는 청크를 통합한 후 처리하지만, 서버 측에서는 전송에 필요한 블록만 메모리에 로드해 TCP 소켓에 데이터를 실어 보낼 수 있다. 따라서 1GB짜리 동영상 파일을 보내는 경우라도 메모리를 1GB 소비하는 일은 없다. 클라이언트 측의 장점으로는 서버 측에서 마지막 데이터 준비가 됐을 무렵엔 그 전까지의 데이터는 이미 전송이 끝났으므로 리드 타임을 짧게 할 수 있다. JPEG, GIF, PNG라면 다운로드된 부분만 표시하거나 인터레이스 방식 표시도 할 수 있으므로 사용자에 대한 응답속도도 빨라진다.
청크의 구조는 아래와 같다.
HTTP/1.1 200 OK
Date: Sun, 3 Mar 2024 00:50:21 GMT
Content-Type: video/webm
Transfer-Encoding: chunked
186a0
(100KB분의 데이터)
186a0
(100KB분의 데이터)
186a0
(100KB분의 데이터)
0
바디는 몇 개의 데이터 덩어리로 나뉘어 있다. 우선 16진수로 표시된 파일 크기가 표시되어 있고, 그 뒤로 지정한 크기만큼 데이터가 이어진다. Transfer-Encoding: chunked가 설정됐을 때는 Content-Length 헤더를 포함해선 안 된다고 RFC에 정의되어 있다. 데이터 크기는 지정된 크기의 합계가 된다. 마지막으로 0을 보내면 청크 전송이 모두 끝났다는 신호가 된다.
청크는 다운로드뿐만이 아니라 업로드에서도 사용할 수 있다. 업로드할 때도 형식은 똑같다.
7.1 메시지 끝에 헤더 추가
청크 형식으로 전송하는 경우에 청크된 메시지 끝에 헤더를 추가할 수 있게 됐다.
Trailer: Content-Type
'여기서 부여한 헤더는 바디를 보낸 후 전송된다'라고 알려준다. 청크 형식으로만 사용할 수 있다는 것은 청크 형식임을 사전에 알 수 있게 해야 하므로, 이를 위해 필요한 헤더는지정할 수 없다. 또한 Trailer 자신을 나중에 보낼 수 없다. 따라서 다음의 헤더는 지정할 수 없다.
- Transfer-Encoding
- Content-Length
- Trailer
8.0 바디 전송 확인
클라이언트에서 서버로 한 번에 데이터를 보내는 게 아니라, 일단 받아들일 수 있는지 물어보고 나서 데이터를 보내는 2단계 전송을 할 수 있게 됐다.
우선 클라이언트는 다음 헤더와 바디를 제외한 모든 헤더를 지정해 문의한다. 파일이 없어도 Content-Length 헤더를 함께 보낸다.
Expect: 100-continue
만약 서버로부터 아래와 같은 응답이 돌아왔다면, 서버가 처리할 수 있다는 말이므로 바디를 붙여 다시 전송한다.
100 Continue
서버가 지원하지 않으면 417 EXPECTATION FAILED가 돌아오기도 한다.
curl 커맨드는 기본적으로 이 헤더를 전송해 2단계로 포스트한다. 전송할 콘텐츠의 크기가 1025 바이트 이상이면 이렇게 동작한다. 이를 억제하려면 아래와 같이 Expect 헤더를 비워서 보낸다.
curl -H "Expect:" --data-binary @bigfile.txt http://localhost:18888
Reference
- 리얼월드 HTTP : 역사와 코드로 배우는 인터넷과 웹 기술
'개발 서적 > 리얼월드 HTTP' 카테고리의 다른 글
Go 언어를 이용한 HTTP/1.1 클라이언트 구현 (0) | 2024.03.08 |
---|---|
HTTP/1.1의 시맨틱스: 확장되는 HTTP의 용도 (0) | 2024.03.03 |
검색 엔진용 콘텐츠 접근 제어 (0) | 2024.02.14 |
리퍼러 (0) | 2024.02.14 |
프록시를 사용한 외부 캐시와 필터링 (0) | 2024.02.13 |