MyKVM (7) 메시지마다 AES-GCM으로 잠그고 순번으로 재생 막기
이 시리즈는 내가 직접 쓴 개발기가 아니다. 무엇을 만들지 정해 Claude Code 에게 앱을 만들게 했고, 만든 과정과 쓰인 기술은 Claude Code 가 정리했다. 나는 방향을 정하고, 실기기에서 써 보며 고칠 곳을 찾아 다시 맡겼다. 그래도 글로 남기는 건 공부 때문이다. 아직 내가 아는 프레임워크가 몇 개 없어서, 이 앱에 쓰인 여러 프레임워크를 정리해 두고 나중에 하나씩 찾아보며 익히려고 한다.
5편에서 접속할 때마다 방향별 키 두 개를 새로 만들었다. 이제 그 키로 메시지 하나하나를 봉투에 넣어 잠근다. 커서 위치, 키 누름, 하트비트까지 ready 뒤에 오가는 메시지는 전부 이 봉투를 탄다. 이번 글은 봉투의 모양과, 그 봉투를 TCP 연결 위에서 한 장씩 나누는 방법이다.
봉투 한 장의 모양
| 칸 | 크기 | 하는 일 |
|---|---|---|
| 길이 | 4바이트 | 뒤에 오는 봉투가 몇 바이트인지. TCP에서 봉투를 한 장씩 나눌 때 쓴다 |
| 순번(seq) | 8바이트 | 보낸 순서대로 1, 2, 3… 잠그지 않고 그대로 보낸다 |
| 암호문 | 메시지마다 다름 | 메시지 JSON을 잠근 것 |
| 태그 | 16바이트 | 봉투가 중간에 바뀌지 않았다는 도장 |
AES-GCM이란?
메시지를 잠그는 일과 도장을 찍는 일을 한 번에 하는 암호 방식이다. 편지를 금고에 넣고, 금고 겉에 봉인 스티커까지 붙이는 것과 비슷하다. 키가 없으면 금고를 열 수 없고(암호화), 누가 금고를 건드리면 스티커가 찢어진 게 보인다(인증). 이 스티커가 16바이트 태그다.
암호문 한 바이트만 바꿔도, 순번을 바꿔도 받는 쪽에서 태그가 맞지 않아 열기 자체가 실패한다. 그래서 “잠기긴 했는데 내용이 몰래 바뀐 메시지”를 받을 일이 없다.
논스란?
“한 번만 쓰는 번호”(number used once)다. AES-GCM은 잠글 때마다 키와 함께 12바이트 논스를 넣는다. 같은 키로 같은 논스를 두 번 쓰면, 두 암호문을 비교해서 내용을 짐작하거나 도장을 위조할 수 있게 된다. 그래서 같은 키 아래에서는 논스가 절대 겹치면 안 된다.
MyKVM은 순번을 논스로 쓴다. 앞에 0을 4바이트 붙여 12바이트로 맞춘다. 순번은 1부터 하나씩 올라가니 한 접속 안에서는 겹칠 수 없고, 5편에서 본 것처럼 키가 접속마다, 방향마다 새로 만들어지니 다른 접속이나 반대 방향과도 겹치지 않는다. 무작위 논스를 따로 만들어 붙이지 않아도 되는 이유다.
맥 쪽 잠그기와 열기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// SecureChannel.swift (KVMCore)
public mutating func seal(_ plaintext: Data) throws -> Data {
sentSeq += 1
let seq = Self.bytes(sentSeq)
let box = try AES.GCM.seal(plaintext, using: sendKey, nonce: Self.nonce(seq), authenticating: seq)
return seq + box.ciphertext + box.tag
}
public mutating func open(_ frame: Data) throws -> Data {
guard frame.count >= 24 else { throw SecureChannelError.tooShort }
let frame = Data(frame)
let seqBytes = frame.prefix(8)
let seq = seqBytes.reduce(UInt64(0)) { $0 << 8 | UInt64($1) }
// 같은 프레임을 다시 보내는 공격(재생)을 막는다. 순서가 뒤로 가는 것도 거절
guard seq > receivedSeq else { throw SecureChannelError.replay }
do {
let box = try AES.GCM.SealedBox(nonce: Self.nonce(seqBytes), ciphertext: frame.dropFirst(8).dropLast(16), tag: frame.suffix(16))
let plain = try AES.GCM.open(box, using: receiveKey, authenticating: seqBytes)
receivedSeq = seq
return plain
} catch {
throw SecureChannelError.authenticationFailed
}
}
private static func nonce(_ seq: Data) throws -> AES.GCM.Nonce {
try AES.GCM.Nonce(data: Data(count: 4) + seq)
}
AES.GCM.seal과 open으로 봉투 만들기
AES.GCM.Nonce(data:)로 0 네 바이트와 순번 여덟 바이트를 이어 붙인 12바이트를 논스로 만든다.AES.GCM.seal(_:using:nonce:authenticating:)이 평문을 잠근다.authenticating:에 넣은 순번은 잠기지 않지만 도장에는 들어간다.- 돌려받은 상자에서
ciphertext와tag를 꺼내 순번 뒤에 이어 붙이면 봉투 한 장이 된다. - 받는 쪽은 봉투를 순번(앞 8바이트), 암호문(가운데), 태그(뒤 16바이트)로 잘라
AES.GCM.SealedBox를 다시 만든다. AES.GCM.open(_:using:authenticating:)이 태그를 확인하고 평문을 돌려준다. 태그가 안 맞으면 오류를 던진다.
순번을 잠그지 않고 그대로 보내는 건, 받는 쪽이 열기 전에 논스를 알아야 하기 때문이다. 대신 authenticating:으로 도장에 넣어 두니, 누가 순번만 바꿔치기해도 열기가 실패한다. 이렇게 잠그지는 않고 도장에만 넣는 값을 “추가 인증 데이터”(AAD)라고 부른다.
봉투는 순번 8바이트와 태그 16바이트만으로 이미 24바이트라, 그보다 짧으면 열어 보지도 않고 버린다.
같은 봉투를 다시 보내는 공격
잠그고 도장까지 찍어도 막지 못하는 공격이 하나 남는다. 봉투를 바꾸지 않고 그대로 다시 보내는 것이다.
재생 공격이란?
잠긴 봉투를 열지 못해도, 봉투째 녹음해 뒀다가 나중에 다시 틀어 주는 공격이다. 같은 공유기에 있는 누군가가 “Enter 키 누름” 봉투를 잡아 뒀다가 다시 보내면, 봉투 자체는 진짜라 태그가 맞는다. 내용을 몰라도 같은 동작을 다시 일으킬 수 있다.
그래서 받는 쪽은 마지막으로 연 순번을 기억하고, 그보다 크지 않은 순번은 열기 전에 거절한다. TCP는 보낸 순서대로 도착을 보장하니, 정상적인 봉투는 언제나 순번이 하나씩 커진다. 순번이 같거나 작은 봉투는 다시 틀어 준 것뿐이다. 마지막 순번은 태그 확인까지 통과한 뒤에만 올린다. 가짜 봉투에 큰 순번을 적어 보내 진짜 봉투를 막는 일을 피하려는 것이다.
맥 테스트에는 같은 봉투를 두 번 열면 두 번째가 replay로 거절되는지, 봉투 마지막 바이트를 바꾸면 authenticationFailed가 나는지 보는 항목이 있다.
윈도우 쪽 잠그기와 열기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// SecureChannel.cs (MyKVM.Core)
readonly AesGcm _send = new(sendKey, 16);
readonly AesGcm _receive = new(receiveKey, 16);
public byte[] Seal(byte[] plaintext)
{
_sentSeq++;
var frame = new byte[8 + plaintext.Length + 16];
BinaryPrimitives.WriteUInt64BigEndian(frame, _sentSeq);
var seq = frame.AsSpan(0, 8);
_send.Encrypt(Nonce(seq), plaintext, frame.AsSpan(8, plaintext.Length), frame.AsSpan(8 + plaintext.Length), seq);
return frame;
}
static byte[] Nonce(ReadOnlySpan<byte> seq) => [0, 0, 0, 0, .. seq];
AesGcm.Encrypt로 같은 봉투 만들기
.NET의 AesGcm은 키와 태그 길이(16)로 한 번 만들어 두고 계속 쓴다. CryptoKit이 상자를 돌려주는 것과 달리, 결과를 쓸 자리를 미리 만들어 넘기는 방식이다.
Encrypt 인자 | 넘긴 것 | CryptoKit의 짝 |
|---|---|---|
nonce | 0 네 바이트 + 순번 | nonce: |
plaintext | 메시지 JSON | 첫 번째 인자 |
ciphertext | 봉투의 9번째 바이트부터 평문 길이만큼 | box.ciphertext |
tag | 봉투의 마지막 16바이트 | box.tag |
associatedData | 봉투 앞 8바이트(순번) | authenticating: |
봉투 크기만큼 배열을 한 번 만들고, 앞 8바이트에 순번을 쓴 뒤 그 뒤 칸에 암호문과 태그를 바로 채운다. BinaryPrimitives.WriteUInt64BigEndian은 숫자를 빅엔디언 바이트로 쓰는 함수다. 맥과 윈도우가 같은 봉투를 만드는지는 6편의 테스트 벡터로 확인했다.
TCP 위에서 봉투 한 장씩 나누기
TCP는 보낸 바이트가 순서대로, 빠짐없이 도착하게 해 주지만, “여기까지가 한 덩어리”라는 경계는 지켜 주지 않는다. 물이 흐르는 관과 같다. 컵 세 잔을 부어도 받는 쪽에는 물줄기로만 온다. 봉투 두 장이 한 번에 붙어 오기도 하고, 한 장이 반으로 잘려 두 번에 나눠 오기도 한다.
그래서 봉투 앞에 길이를 4바이트로 붙인다. 받는 쪽은 길이를 먼저 읽고, 그만큼 바이트가 모일 때까지 기다렸다가 한 장을 꺼낸다.
빅엔디언이란?
여러 바이트짜리 숫자를 큰 자리부터 적는 순서다. 1000을 4바이트로 쓰면 00 00 03 E8이 된다. 맥과 윈도우 PC의 CPU는 메모리 안에서 작은 자리부터 적지만(리틀엔디언), 네트워크로 보내는 숫자는 큰 자리부터 쓰는 게 관례라 길이와 순번 모두 빅엔디언으로 맞췄다. 양쪽이 같은 순서로만 읽으면 되니, 어느 쪽이든 하나로 정해 두는 게 중요하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Framing.swift (KVMCore)
/// 프레임 하나의 최대 크기. 이보다 크다고 주장하는 길이는 깨진 연결로 본다.
public let maxFrameLength = 1_048_576
public mutating func append(_ data: Data) throws -> [Data] {
buffer.append(data)
var frames: [Data] = []
while buffer.count >= 4 {
let length = buffer.prefix(4).reduce(0) { $0 << 8 | Int($1) }
guard length <= maxFrameLength else { throw FramingError.tooLarge }
guard buffer.count >= 4 + length else { break }
frames.append(Data(buffer.dropFirst(4).prefix(length)))
buffer = Data(buffer.dropFirst(4 + length))
}
return frames
}
받은 조각을 버퍼에 쌓고, 길이 4바이트를 읽을 수 있고 그 길이만큼 다 모였으면 한 장을 꺼낸다. 한 번 받은 조각에 여러 장이 있으면 반복해서 다 꺼내고, 모자라면 다음 조각을 기다린다.
길이가 1MB보다 크면 연결이 깨진 것으로 보고 끊는다. 가장 큰 메시지인 화면 목록도 몇백 바이트라, 그보다 훨씬 큰 길이는 중간에 바이트가 어긋났다는 뜻이다. 이 검사가 없으면 엉뚱한 4바이트를 길이로 읽어 수 GB가 올 때까지 기다리게 된다.
ready 전의 페어링과 인증 메시지는 아직 키가 없으니 이 길이 칸 뒤에 JSON을 그대로 싣는다. 담긴 것이 PC 이름, 공개 키, 무작위 값, 증명 정도라 엿들어도 토큰이나 키를 알아낼 수 없다. ready 뒤로는 같은 길이 칸 뒤에 봉투를 싣는다.
