MyKVM (5) 처음 한 번 페어링하는 키 교환과 6자리 숫자
이 시리즈는 내가 직접 쓴 개발기가 아니다. 무엇을 만들지 정해 Claude Code 에게 앱을 만들게 했고, 만든 과정과 쓰인 기술은 Claude Code 가 정리했다. 나는 방향을 정하고, 실기기에서 써 보며 고칠 곳을 찾아 다시 맡겼다. 그래도 글로 남기는 건 공부 때문이다. 아직 내가 아는 프레임워크가 몇 개 없어서, 이 앱에 쓰인 여러 프레임워크를 정리해 두고 나중에 하나씩 찾아보며 익히려고 한다.
MyKVM은 누른 키를 네트워크로 보낸다. 같은 집 공유기 안이라도 키 입력이 그대로 흐르는 건 찜찜하다. 비밀번호를 칠 때도 이 길을 탄다. 그래서 TouchDeck 때처럼 처음 한 번 페어링을 하고, 그 뒤로는 모든 메시지를 암호화한다. 이번 글은 페어링에서 맥과 윈도우가 둘만 아는 비밀 값을 만드는 방법이다.
페어링 순서
윈도우 앱이 처음 맥에 붙으면 맥이 6자리 숫자를 창에 띄운다. 그 숫자를 윈도우에 입력하면 양쪽이 같은 비밀 값(토큰)을 갖게 된다. 토큰은 네트워크로 한 번도 오가지 않는다. 이때만 키보드가 윈도우에 있어야 해서, 하드웨어 KVM으로 넘어가 숫자를 친다.
| 순서 | 방향 | 메시지 | 담긴 것 |
|---|---|---|---|
| 1 | 윈도우 → 맥 | pairHello | 윈도우 이름, 윈도우 공개 키 |
| 2 | 맥 → 윈도우 | pairOffer | 맥 공개 키. 맥은 이때 6자리 숫자를 띄운다 |
| 3 | 윈도우 → 맥 | pairProof | “나도 그 숫자를 안다”는 증명 |
| 4 | 맥 → 윈도우 | pairDone | 맥의 증명. 맞으면 양쪽이 토큰을 저장한다 |
숫자를 틀리게 치면 3번 증명이 맞지 않아 맥이 pairFail을 보내고 끊는다. 맥 창에서 “거절”을 눌러도 같은 메시지가 간다. 윈도우는 이걸 받으면 트레이 메뉴의 “페어링 다시 하기”를 누르기 전까지 다시 조르지 않는다. 처음에는 거절해도 윈도우가 1초 뒤 바로 다시 붙어서, 맥에 페어링 창이 계속 튀어나왔다.
키 교환이란?
두 사람이 남들이 다 듣는 곳에서 이야기하면서도 둘만 아는 비밀 값을 만드는 방법이다. 흔히 물감 섞기로 비유한다. 둘이 같은 바탕색을 공개로 정하고, 각자 비밀 색을 하나씩 골라 바탕색에 섞어 상대에게 건넨다. 받은 색에 자기 비밀 색을 한 번 더 섞으면 둘 다 같은 색이 나온다.
중간에서 섞은 색 두 개를 엿들은 사람은 섞인 색을 다시 풀 수 없어서 같은 색을 만들 수 없다. 여기서 비밀 색이 개인 키, 섞어서 건넨 색이 공개 키, 마지막 색이 공유 비밀이다. 실제로는 물감 대신 타원 곡선이라는 수학 규칙으로 계산하고, MyKVM은 그중 P-256이라는 곡선을 쓴다.
6자리 숫자를 섞는 이유
키 교환만으로는 막지 못하는 공격이 있다. 누군가 맥과 윈도우 사이에 끼어들어 맥과도, 윈도우와도 각각 키 교환을 해 버리는 것이다. 양쪽은 서로와 이야기한다고 믿지만 실제로는 끼어든 사람과 이야기하게 된다.
그래서 맥 화면에 뜬 6자리 숫자를 계산에 섞는다. 숫자는 네트워크로 가지 않고 사람 눈으로만 전달되니, 엿듣기만 하는 사람이나 나중에 “나도 그 윈도우야”라고 나서는 사람은 같은 토큰을 만들 수 없다. 숫자가 다르면 증명이 맞지 않아 페어링이 실패한다.
다만 이 방식에는 한계가 있다. 페어링하는 바로 그 순간에 누군가 중간에서 양쪽과 따로 키 교환을 하고 있다면, 윈도우가 보낸 증명을 받아 들고 000000부터 999999까지 100만 개를 전부 대입해 볼 수 있다. 컴퓨터로는 순식간이라 숫자를 알아내고 맥 쪽 페어링까지 마칠 수 있다. 이걸 막으려면 PAKE(비밀번호 기반 키 교환)처럼 대입해 볼 단서 자체를 남기지 않는 방식이 필요해서, 남은 일로 적어 뒀다. 페어링은 집 공유기 안에서 처음 한 번만 하고, 그 뒤의 접속은 토큰으로만 확인하니 지금은 이 정도로 두었다.
맥 쪽 계산
1
2
3
4
5
6
7
8
9
10
11
12
13
// Pairing.swift (KVMCore)
let peer = try P256.KeyAgreement.PublicKey(x963Representation: peerPublicKey)
let shared = try privateKey.sharedSecretFromKeyAgreement(with: peer)
// 두 공개 키를 같이 넣어서, 다른 키 쌍으로 만든 결과와 섞일 수 없게 한다
let transcript = macPublicKey + clientPublicKey
let salt = Data(code.utf8)
let confirmKey = shared.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt,
sharedInfo: Data("mykvm confirm".utf8) + transcript, outputByteCount: 32)
let token = shared.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt,
sharedInfo: Data("mykvm token".utf8) + transcript, outputByteCount: 32)
return PairingDerived(token: token.withUnsafeBytes { Data($0) },
clientProof: hmacHex(key: confirmKey, "client"),
macProof: hmacHex(key: confirmKey, "mac"))
P256.KeyAgreement와 HKDF로 토큰 만들기
P256.KeyAgreement.PrivateKey는 CryptoKit의 키 교환용 개인 키다. 공개 키는x963Representation으로 65바이트 데이터가 되고, 받은 쪽은PublicKey(x963Representation:)로 다시 키로 바꾼다.sharedSecretFromKeyAgreement(with:)가 내 개인 키와 상대 공개 키로 공유 비밀(물감 섞기의 마지막 색)을 만든다. 돌려주는SharedSecret은 그대로 키로 쓰지 않고, 다음 단계로 용도별 키를 뽑는다.hkdfDerivedSymmetricKey(using:salt:sharedInfo:outputByteCount:)가 공유 비밀에서 키를 뽑는다.salt에 6자리 숫자를,sharedInfo에 용도 글자와 두 공개 키를 넣는다.HMAC으로 증명을 만든다(hmacHex는 그 결과를 16진수 글자로 바꾸는 작은 도우미).
공개 키 두 개(transcript)를 같이 섞는 건, 이 계산이 정확히 이 두 키 사이의 페어링에서만 나오게 하려는 것이다.
HKDF란?
비밀 값 하나에서 서로 다른 키를 여러 개 안전하게 뽑아내는 방법이다. 같은 공유 비밀을 넣어도 “용도 글자”(sharedInfo)가 다르면 전혀 다른 키가 나온다. 그래서 “mykvm confirm”으로 뽑은 확인용 키와 “mykvm token”으로 뽑은 토큰이 서로 섞일 일이 없다. 한 열쇠 원본으로 현관 열쇠와 우편함 열쇠를 따로 깎는 것과 비슷하다. 원본을 모르면 둘 다 못 깎고, 하나를 잃어버려도 다른 하나는 안전하다.
HMAC이란?
비밀 키가 있어야만 만들 수 있는 짧은 도장이다. 같은 키와 같은 글자를 넣으면 늘 같은 도장이 나오고, 키를 모르면 맞는 도장을 만들 수 없다. 페어링에서는 확인용 키로 “client”와 “mac”이라는 글자에 도장을 찍는다. 윈도우가 먼저 자기 도장을 보내고, 맥이 자기도 같은 도장을 만들 수 있는지 확인한 뒤 맥 도장을 돌려준다. 양쪽 다 같은 6자리 숫자를 알아야 같은 확인용 키가 나오니, 도장이 맞으면 “상대도 같은 숫자를 봤다”는 뜻이다.
도장을 비교할 때는 앞에서부터 한 글자씩 비교하다 다르면 바로 멈추는 방식을 쓰지 않는다. 멈추는 시점의 차이로 몇 글자까지 맞았는지 새어 나갈 수 있어서, 끝까지 다 비교하는 함수를 따로 만들었다.
다시 붙을 때
페어링은 처음 한 번뿐이다. 그 뒤로는 연결할 때마다 토큰으로 서로를 확인하고, 이번 접속에서만 쓸 키를 새로 만든다.
| 순서 | 방향 | 메시지 | 담긴 것 |
|---|---|---|---|
| 1 | 윈도우 → 맥 | hello | 윈도우 번호, 윈도우가 고른 무작위 값 |
| 2 | 맥 → 윈도우 | challenge | 맥이 고른 무작위 값, 맥의 증명 |
| 3 | 윈도우 → 맥 | auth | 윈도우의 증명 |
| 4 | 맥 → 윈도우 | ready | 여기부터 모든 메시지 암호화 |
1
2
3
4
5
6
7
8
// SessionKeys.swift (KVMCore)
let key = SymmetricKey(data: token)
let nonces = "\(clientNonce)|\(macNonce)"
macProof = hmacHex(key: key, "mac|\(nonces)")
clientProof = hmacHex(key: key, "client|\(nonces)")
let salt = Data(nonces.utf8)
clientToMac = HKDF<SHA256>.deriveKey(inputKeyMaterial: key, salt: salt, info: Data("mykvm c2m".utf8), outputByteCount: 32)
macToClient = HKDF<SHA256>.deriveKey(inputKeyMaterial: key, salt: salt, info: Data("mykvm m2c".utf8), outputByteCount: 32)
SymmetricKey와 HKDF.deriveKey로 접속마다 새 키 만들기
SymmetricKey는 CryptoKit의 대칭 키(양쪽이 같은 키를 쓰는 방식) 타입이다. 저장해 둔 토큰을 키로 감싼다. HKDF.deriveKey(inputKeyMaterial:salt:info:outputByteCount:)는 위의 HKDF와 같은 계산을 키 하나에서 바로 한다. 양쪽이 낸 무작위 값을 salt로 넣으니 접속할 때마다 다른 키가 나오고, 맥에서 윈도우로 가는 키(m2c)와 윈도우에서 맥으로 가는 키(c2m)를 따로 뽑는다.
맥이 먼저 자기 증명을 보내는 건 가짜 맥을 걸러 내려는 것이다. 공유기에 누가 “나도 MyKVM 맥이야”라고 나서도 토큰을 모르니 맞는 증명을 못 만들고, 윈도우는 그 자리에서 연결을 끊는다.
X25519 대신 P-256을 고른 이유
TouchDeck에서는 X25519라는 곡선과 ChaChaPoly 암호를 썼다. 이번에는 윈도우 쪽 C#이 같은 계산을 해야 하는데, .NET 기본 라이브러리에는 X25519가 없다. 외부 라이브러리를 넣으면 윈도우 쪽에 신경 쓸 게 늘어난다. 그래서 맥(CryptoKit)과 윈도우(.NET) 기본 라이브러리에 다 있는 P-256과 AES-GCM으로 바꿨다. 두 언어로 같은 계산을 따로 짰으니 결과가 정말 같은지 확인해야 하는데, 그 방법은 다음 편 주제다.
