원격 접속 시, 보안을 위해 ID/PW 방식의 인증 대신 공개키 기반의 인증을 구성하는 경우가 많다.
OpenSSH 포맷의 SSH 키 쌍을 생성했을 때의 키 내부 구조를 상세히 알아보고,
SSH 연결을 맺는 과정에서 이 키가 어떻게 사용되는지 알아본다.
상세한 SSH 핸드셰이킹 과정은 별도의 포스트에서 다룬다.
키 생성
SSH 접속을 위해서는 비대칭키 쌍을 만들고, 서버 측에 공개키를, 클라이언트 측에 개인키를 보관한다.
다음 명령을 실행하여 ed25519 타입의 키 쌍을 생성하면 mykey, mykey.pub 파일이 생성된다.
ssh-keygen -t ed25519 -f mykey
mykey는 개인키, mykey.pub은 공개키이며, 공개키인 mykey.pub을 서버의 .authorized_keys 파일에 추가함으로써 연결할 준비를 마친다.
Passphrase
키 생성 시 Passphrase를 입력하라는 프롬프트가 나오는데, 이는 생성한 개인키 내용을 암호화하기 위한 비밀번호다.
개인키 자체를 도난당하더라도 Passphrase 값을 통해 암호화 되어 있기 때문에, Passphrase까지 알고 있어야 개인키를 이용할 수 있다.
따라서, 보안 수준을 높이기 위해 Passphrase를 입력하여 개인키 자체를 암호화할 수 있다.
이번 포스트의 흐름은 이 Passphrase에서 출발한다.
Passphrase를 입력하여 생성한 개인키를 이용하여 ssh 접속을 시도할 때, ssh 클라이언트는 사용자에게 Passphrase를 입력하라는 프롬프트를 내민다.
개인키가 암호화 되었다는 것을 ssh 클라이언트가 어떻게 알았을까?
만약 mykey 파일의 내용의 처음부터 끝까지 통째로 암호화 해버렸다면, ssh 클라이언트는 mykey 파일의 내용을 해석할 수 있는 방법을 모른다.
mykey라는 파일이 어떤 알고리즘과 절차로 암호화 되었는지 알기 위해서는 ssh 클라이언트가 별도의 파일에 해당 정보를 기록해야 할 텐데,
개인키를 다른 컴퓨터로 옮겨도 문제없이 키를 이용할 수 있기 때문에 별도의 파일에 저장하지 않는다고 본다.
이는 즉 mykey 파일만 보고도 '이 개인키는 암호화 되었구나', '어떤 암호화 알고리즘을 사용했구나' 등을 알 수 있다는 얘기이며,
따라서 mykey 파일의 모든 내용이 암호화되는 것은 아니라고 볼 수 있다.
어느 부분이 암호화 되는지 알아보기 전에 개인키 내부 구조를 이해해야 한다.
개인키 내부 구조
개인키 파일 내부에는 크게 헤더, 암호화 정보, 공개키, 개인키 4가지가 기록되어 있다.
이 중 개인키 부분이 Passphrase를 통해 암호화되며,암호화 정보 부분은 개인키에 대한 암호화 정보를 담고 있다.
SSH String
개인키 파일에는 여러가지 값이 기록되어 있고, 이를 바이너리 형식으로 저장한다.
단순히 0과 1이 나열된 데이터를 어떻게 해석할지에 대한 규칙 중,
문자열을 표현하는 규칙은 다음과 같다.
"<값의 길이> <값> 형식으로 기록하며, 값의 길이를 표현하는 부분은 항상 4바이트를 차지한다."
가령, 필드의 길이가 00 00 00 05라면 직후 5바이트에 값을 담고 있다는 것이다.
개인키 내용 해석 원리
mykey와 같은 개인키를 읽고 해석하는 과정은 다음과 같다.
(1) 첫 15바이트를 헤더로 취급하여, 헤더를 읽는다.
(2) 이후의 대부분 값은 앞서 언급한 SSH String 구조를 반복한다.
최종적으로 개인키는 다음의 구조를 가진다.
<헤더>
<필드1 길이> <필드1 값>
<필드2 길이> <필드2 값>
<필드3 길이> <필드3 값>
...
개인키의 필드 순서에 따라 예외적인 부분이 있으며, 아래에서 상세한 필드 종류와 함께 다시 알아본다.
상세 구조
앞서 언급했듯 개인키 파일은 크게 4부분으로 나뉘며, 각 부분의 상세 필드에 대해 정리한다.
1. 헤더 (Magic String)
첫 15바이트를 고정으로 차지하며,
개인키 파일의 포맷과 버전 등을 표현한다.
예시: open-ssh-v1
2. 암호화 정보
ℹ️ KDF (Key Derivation Function, 키 파생 함수)
개인키 본문을 암호화할 때, Passphrase는 그대로 사용하지 않고 해시를 사용한다.
Passphrase를 가공하여 길이를 늘리고, salt라고 부르는 혼합물을 섞고, 해싱 알고리즘을 여러번 반복(round)해서 최종 해시를 생성하며,
이 일련의 과정을 수행하는 해싱 함수를 KDF 라고 부르며, bcrypt는 그 일종이다.
Passphrase로 암호화한 정보를 다음 순서대로 표현한다.
Chiper: 암호화 알고리즘KDF: 해싱에 사용한 함수 이름KDF Options: salt, round 값 등 해싱에 사용된 세부 옵션Number of keys: 이 키파일 안에 몇개의 키 쌍이 보관되어 있는지. 기본적으로 1이며, 확장성을 위해 설계된 필드일 뿐 실제로는 거의 쓰이지 않는다고 한다.
이 중 Number of keys는 SSH String이 아니라 4바이트 integer이다.
3. 공개키 블럭
키 생성 알고리즘: ed25519, rsa 등키 본문: 실제 통신에 사용되는 키 알맹이
4. 개인키 블럭
Passphrase로 암호화되는 덩이리다.
키 사용 시 사용자로부터 입력 받은 Passphrase로 복호화를 수행할 텐데,
복호화가 잘 되었는지 검증함으로써 Passphrase를 validation한다.
체크섬1: 키 생성 시 생성한 난수체크섬2: 체크섬1과 동일한 값키 생성 알고리즘: ed25519, rsa 등공개키 본문: 위 공개키 블럭에 포함된 알맹이개인키 본문: 공개키와 마찬가지로, 실제 통신에 사용되는 키 알맹이주석
복호화 검증
- 체크섬 검증
최초 키 생성 시 체크섬1과 체크섬2 각 4바이트에 동일한 난수를 지정한다.
올바른 Passphrase를 입력했다면, 복호화된 데이터에 이상이 없으므로 체크섬1 부분(1-4바이트)과 체크섬2 부분(5-8바이트)의 값이 일치한다.
하지만 틀린 값을 입력하게 되면 복호화된 값은 해석할 수 없는 쓰레기 값으로 변한다. 이 때는 1-4바이트의 값과 5-8바이트의 값이 일치하지 않게 된다.
- 공개키 본문 검증
공개키 블럭은 암호화되지 않는다.
따라서 공개키 블럭에 포함된 공개키 본문과 복호화하여 확인한 공개키 본문이 일치하는지 확인한다.
OpenSSH 포맷의 개인키 최종 구조
<header (15 bytes)>
# Passphrase 암호화 정보
<cipher 길이 (4 bytes)> <cipher 값>
<kdf 길이 (4 bytes)> <kdf 값>
<kdf options 길이 (4 bytes)> <kdf options 값>
<number of keys (4 bytes)>
# 공개키 블럭
<공개키 알고리즘 길이 (4 bytes)> <공개키 알고리즘 값>
<공개키 본문 길이 (4 bytes)> <공개키 본문 값>
# 개인키 블럭. 이 부분만 Passphrase로 암호화됨.
<개인키 체크섬1 (4 bytes)>
<개인키 체크섬2 (4 bytes)>
<공개키 본문 길이 (4 bytes)> <공개키 본문 값>
<개인키 본문 길이 (4 bytes)> <개인키 본문 값>
<주석 길이 (4 bytes)> <주석 값>
SSH 연결 시 키 사용 과정
개인키 내부 구조에서 확인했듯, mykey라는 개인키 파일 안에는 공개키 또한 포함되어 있다.
ssh 핸드셰이킹 과정에서 클라이언트 측은 개인키 뿐만 아니라 공개키도 활용한다.
핸드셰이킹 과정에서 키가 활용되는 부분은 다음과 같이 정리된다.
1. 연결 요청 (Query)
(1) 클라이언트: 서버에게 mykey 내부의 공개키를 보낸다.
(2) 서버: .authorized_keys 파일을 확인하여 해당 공개키가 있는지 확인한다. 있다면 난수를 하나 생성하여 응답한다.
2. 사용자 인증 (Challenge)
서버가 클라이언트에게 도전과제를 내준다.
난수를 던져주고, 이걸 개인키로 암호화 해보라는 것이다.
(1) 서버: 클라이언트에게 난수 전달
(2) 클라이언트: 개인키로 난수를 암호화하여 서버에게 다시 전달
(3) 서버: 공개키로 난수를 복호화하여 검증
이 때 개인키가 암호화되어 있다면 사용자에게 Passphrase 입력을 요청한다.
이 도전과제를 해내면 ssh 연결이 수립되어, 원격 셸을 이용한다.