포스트

MyKVM (6) 맥과 윈도우가 같은 값을 내는지 확인하는 테스트 벡터

이 시리즈는 내가 직접 쓴 개발기가 아니다. 무엇을 만들지 정해 Claude Code 에게 앱을 만들게 했고, 만든 과정과 쓰인 기술은 Claude Code 가 정리했다. 나는 방향을 정하고, 실기기에서 써 보며 고칠 곳을 찾아 다시 맡겼다. 그래도 글로 남기는 건 공부 때문이다. 아직 내가 아는 프레임워크가 몇 개 없어서, 이 앱에 쓰인 여러 프레임워크를 정리해 두고 나중에 하나씩 찾아보며 익히려고 한다.

5편의 키 교환은 맥에서 Swift로, 윈도우에서 C#으로 두 번 짰다. 메시지 형식도 마찬가지다. 두 쪽이 한 바이트라도 다르게 계산하면 페어링이 실패하는데, 실기기에서는 “숫자가 틀렸다”는 말만 보일 뿐 어디가 다른지 알 수 없다. 그래서 윈도우에 옮기기 전에 맥에서 두 구현을 맞춰 보는 장치를 먼저 만들었다.


같은 계산을 두 언어로

두 구현이 같은지 보려면 둘에게 같은 문제를 주고 답을 비교하면 된다. 이때 쓰는 것이 테스트 벡터다.


테스트 벡터란?

정해진 입력과, 그 입력을 넣었을 때 나와야 하는 정답을 묶어 둔 표다. 같은 시험지를 두 학생이 따로 푸는데 답안지가 한 장 있는 것과 같다. 맥 쪽이 답안지를 만들고, 윈도우 쪽은 같은 문제를 풀어 답안지와 똑같은지 맞춰 본다.

평소 키 교환은 매번 새 무작위 개인 키를 쓰니 결과가 매번 다르다. 그래서 시험할 때만 개인 키를 고정 값으로 정한다. 입력이 같으면 맞게 짠 두 구현은 반드시 같은 답을 낸다.


맥이 만드는 답안지

1
2
3
4
5
6
7
8
9
10
// Vectors.swift (KVMCore)
let macRaw = Data((0x01...0x20).map { UInt8($0) }), clientRaw = Data((0x21...0x40).map { UInt8($0) })
let mac = try P256.KeyAgreement.PrivateKey(rawRepresentation: macRaw)
let client = try P256.KeyAgreement.PrivateKey(rawRepresentation: clientRaw)
let code = "482915"
let derived = try PairingCrypto.derive(privateKey: mac, peerPublicKey: clientPub, code: code,
                                       macPublicKey: macPub, clientPublicKey: clientPub)
let clientNonce = "c-nonce-0001", macNonce = "m-nonce-0001"
let keys = SessionKeys(token: derived.token, clientNonce: clientNonce, macNonce: macNonce)
// 생략: 메시지 세 개를 암호화한 결과까지 묶어서 돌려준다

개인 키를 01부터 20까지, 21부터 40까지 늘어놓은 32바이트로 정하고, 6자리 숫자와 무작위 값도 글자로 고정했다. gen-vectors라는 작은 실행 파일이 이 결과를 protocol/vectors/crypto.json에 쓴다.

묶음입력정답
pairing두 개인 키, 6자리 숫자두 공개 키, 토큰, 양쪽 증명
session토큰, 양쪽 무작위 값양쪽 증명, 방향별 키 두 개
channel키, 평문 메시지 세 개암호화한 결과 세 개

암호화 결과까지 정답으로 둘 수 있는 건, 7편에서 다룰 것처럼 메시지마다 붙는 번호가 1, 2, 3으로 정해져 있어서다. 같은 키와 같은 번호로 같은 글자를 암호화하면 결과가 늘 같다.

맥 테스트에는 디스크의 crypto.json이 지금 코드로 다시 만든 값과 같은지 보는 항목도 있다. 누가 Swift 쪽 계산을 바꾸고 답안지를 다시 안 만들면 맥 테스트가 먼저 실패한다.

1
2
3
4
5
6
// CryptoTests.swift (KVMCoreTests)
@Test func vectorsFileMatchesImplementation() throws {
    // 생략: 저장소 맨 위로 올라가는 경로 계산
    let onDisk = try JSONDecoder().decode(CryptoVectors.self, from: Data(contentsOf: url))
    #expect(onDisk == (try Vectors.make()))
}

윈도우 쪽 답 맞추기

C# 테스트는 같은 crypto.json을 읽어서 같은 고정 개인 키로 계산을 돌리고, 나온 값을 답안지와 비교한다. 이 테스트도 맥에서 dotnet test로 돈다.

1
2
3
4
5
6
7
8
9
10
11
// CryptoVectorTests.cs (MyKVM.Core.Tests)
var v = Vectors.Load();
var mac = PairingCrypto.ImportPrivateKey(Hex.Decode(v.Pairing.MacPrivate));
var client = PairingCrypto.ImportPrivateKey(Hex.Decode(v.Pairing.ClientPrivate));
Assert.Equal(v.Pairing.MacPublic, Hex.Encode(PairingCrypto.ExportPublicKey(mac)));
Assert.Equal(v.Pairing.ClientPublic, Hex.Encode(PairingCrypto.ExportPublicKey(client)));
var d = PairingCrypto.Derive(client, Hex.Decode(v.Pairing.MacPublic), v.Pairing.Code,
                             Hex.Decode(v.Pairing.MacPublic), Hex.Decode(v.Pairing.ClientPublic));
Assert.Equal(v.Pairing.Token, Hex.Encode(d.Token));
Assert.Equal(v.Pairing.ClientProof, d.ClientProof);
Assert.Equal(v.Pairing.MacProof, d.MacProof);

맥은 맥 개인 키와 윈도우 공개 키로 계산했고, 윈도우 테스트는 반대로 윈도우 개인 키와 맥 공개 키로 계산한다. 키 교환이 맞게 짜였다면 방향이 달라도 같은 토큰이 나와야 하니, 실제 페어링과 같은 조건을 시험하는 셈이다.

답안지 파일은 테스트 프로젝트가 빌드될 때 결과 폴더로 복사되게 했다.

1
2
<!-- MyKVM.Core.Tests.csproj -->
<None Include="../../../protocol/vectors/*.json" Link="vectors/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />

CryptoKit과 .NET 함수 짝 맞추기

같은 계산이라도 두 라이브러리는 이름과 모양이 다르다. 어느 함수가 어느 함수의 짝인지가 이 부분의 핵심이었다.

하는 일맥 (CryptoKit)윈도우 (.NET)
P-256 개인 키P256.KeyAgreement.PrivateKeyECDiffieHellman.Create(ECCurve.NamedCurves.nistP256)
공개 키를 65바이트로x963Representation0x04 뒤에 Q.X, Q.Y를 이어 붙임
공유 비밀sharedSecretFromKeyAgreement(with:)DeriveRawSecretAgreement
공유 비밀에서 키 뽑기hkdfDerivedSymmetricKeyHKDF.DeriveKey
증명 도장HMAC<SHA256>HMACSHA256
끝까지 다 비교직접 짠 constantTimeEqualsCryptographicOperations.FixedTimeEquals

공유 비밀 줄이 가장 조심할 곳이다. .NET의 ECDiffieHellman에는 공유 비밀을 꺼내는 함수가 여럿 있는데, 다른 함수들은 공유 비밀을 해시로 한 번 더 가공해서 돌려준다. CryptoKit의 SharedSecret은 가공하지 않은 값이라, 그 짝은 가공 없이 그대로 돌려주는 DeriveRawSecretAgreement다.

공개 키 모양도 맞춰야 했다. CryptoKit의 x963 형식은 “압축 안 한 점”이라는 표시 04 한 바이트 뒤에 X 좌표 32바이트와 Y 좌표 32바이트가 붙은 65바이트다. .NET은 X와 Y를 따로 주니, 윈도우 쪽에서 직접 이어 붙이고 받을 때는 1~32번째, 33~64번째 바이트로 잘라 쓴다.


메시지 형식도 같게

맥과 윈도우가 주고받는 메시지는 JSON 한 덩어리이고, "t" 칸에 메시지 종류를 적는다.

1
2
3
{ "t": "pairHello", "clientId": "c1", "name": "HAROLD-PC", "publicKey": "BAECAwQ=" }
{ "t": "key", "sc": 30, "ext": false, "down": true }
{ "t": "key", "vk": 21, "down": true }

맥은 이걸 Swift 열거형(enum) 하나로, 윈도우는 C# 레코드(record) 여러 개로 나타낸다.


Codable이란?

Swift 값을 JSON 같은 형식으로 바꾸고(인코딩), 다시 값으로 되돌리는(디코딩) 약속이다. Codable을 붙인 구조체는 보통 Swift가 변환 코드를 알아서 만들어 준다. 그런데 열거형에 값이 딸린 경우(case key(sc: Int?, ...))를 알아서 만들면 {"key": {"sc": 30, ...}}처럼 한 겹 더 감싼 모양이 된다. 윈도우와 맞추려면 "t" 칸이 맨 바깥에 있어야 해서, 변환 코드를 직접 썼다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Message.swift (KVMCore)
private enum Key: String, CodingKey {
    case t, clientId, name, publicKey, proof, reason, nonce, x, y, b, down, dx, dy, sc, ext, vk, n, list, v, kind
}

public init(from decoder: Decoder) throws {
    let c = try decoder.container(keyedBy: Key.self)
    switch try c.decode(String.self, forKey: .t) {
    case "pairOffer": self = .pairOffer(publicKey: try c.decode(Data.self, forKey: .publicKey))
    case "key": self = .key(sc: try c.decodeIfPresent(Int.self, forKey: .sc), ext: try c.decodeIfPresent(Bool.self, forKey: .ext),
                            vk: try c.decodeIfPresent(Int.self, forKey: .vk), down: try c.decode(Bool.self, forKey: .down))
    // 생략: 나머지 종류
    case let other: throw DecodingError.dataCorruptedError(forKey: .t, in: c, debugDescription: "unknown message type \(other)")
    }
}

CodingKey와 container로 직접 변환하기

  1. CodingKey를 따르는 열거형 Key가 JSON에 나올 수 있는 칸 이름 목록이다.
  2. decoder.container(keyedBy:)로 JSON 한 덩어리를 “칸 이름으로 꺼내 쓰는 상자”로 연다.
  3. "t" 칸을 먼저 글자로 읽고, 그 값에 따라 필요한 칸만 꺼내 해당 case를 만든다.
  4. 있을 수도 없을 수도 있는 칸은 decodeIfPresent로 읽는다. 키 메시지는 스캔 코드(sc, ext)나 가상 키(vk) 중 하나만 오기 때문이다. 보낼 때도 짝인 encodeIfPresent로 값이 없는 칸은 아예 쓰지 않는다.
  5. 모르는 종류가 오면 오류를 던진다. 받는 쪽은 그 메시지를 버리고 연결을 끊는다.

바이트 데이터(Data)는 JSONEncoder가 기본으로 Base64 글자로 바꾼다. 위의 "BAECAwQ="가 그 모양이다.


JsonPolymorphic이란?

C#의 기본 JSON 라이브러리(System.Text.Json)에서, 부모 타입 하나로 여러 자식 타입을 주고받게 해 주는 표시다. [JsonPolymorphic]에 종류를 적을 칸 이름을 주고, [JsonDerivedType]으로 자식 타입마다 그 칸에 적을 글자를 짝지어 준다. 그러면 "t": "key"가 오면 KeyMessage로 읽고, KeyMessage를 보내면 "t": "key"를 앞에 붙여 쓴다. Swift에서 직접 쓴 변환 코드를 C#에서는 표시 몇 줄로 대신하는 셈이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Messages.cs (MyKVM.Core)
[JsonPolymorphic(TypeDiscriminatorPropertyName = "t")]
[JsonDerivedType(typeof(PairOfferMessage), "pairOffer")]
[JsonDerivedType(typeof(KeyMessage), "key")]
// 생략: 나머지 종류
public abstract record Message;

public sealed record PairOfferMessage(byte[] PublicKey) : Message;
public sealed record KeyMessage(int? Sc, bool? Ext, int? Vk, bool Down) : Message;

static readonly JsonSerializerOptions Options = new()
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    // 맥 JSONEncoder는 키 순서를 보장하지 않는다. "t"가 뒤에 와도 읽어야 한다
    AllowOutOfOrderMetadataProperties = true,
    Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};

JsonSerializerOptions로 맥과 모양 맞추기

설정하는 일왜 필요한가
PropertyNamingPolicyC# 속성 이름 PublicKey를 publicKey로 바꿔 쓴다맥 쪽 칸 이름이 소문자로 시작한다
DefaultIgnoreCondition값이 null인 칸은 쓰지 않는다키 메시지에서 안 쓰는 칸을 맥처럼 빼야 한다
AllowOutOfOrderMetadataProperties"t" 칸이 맨 앞이 아니어도 읽는다기본값으로는 "t"가 맨 앞에 있어야 하는데, 맥은 칸 순서를 정해 주지 않는다
JsonStringEnumConverter마우스 버튼 같은 열거형을 숫자 대신 "left" 같은 글자로 쓴다맥 쪽이 글자로 주고받는다

C#의 byte[]도 기본으로 Base64 글자가 되니, 공개 키 칸은 따로 설정하지 않아도 맥과 같은 모양이 나온다. 전체 동작은 System.Text.Json 다형성 Docs에 있다.


메시지 31개를 양쪽에서 읽어 보기

protocol/vectors/messages.json에는 메시지 종류마다 하나씩 모두 31개의 JSON을 적어 뒀다. 두 종류만 두 줄씩이다. 키 메시지는 스캔 코드판과 가상 키판을, 위치 메시지는 "t"가 맨 앞에 있는 줄과 맨 뒤에 있는 줄({"x":5,"y":6,"t":"pos"})을 넣었다. 뒤의 줄이 AllowOutOfOrderMetadataProperties가 정말 필요한지 확인하는 문제다.

맥과 윈도우 테스트가 각각 이 파일을 읽어서, 모든 줄을 읽고, 다시 쓰고, 그걸 또 읽어서 처음과 같은지 본다.

1
2
3
4
5
6
7
8
9
// ProtocolTests.cs (MyKVM.Core.Tests)
var jsons = LoadVectors();
Assert.Equal(31, jsons.Length);
foreach (var json in jsons)
{
    var first = MessageJson.Encode(MessageJson.Decode(Encoding.UTF8.GetBytes(json)));
    var second = MessageJson.Encode(MessageJson.Decode(first));
    Assert.Equal(Encoding.UTF8.GetString(first), Encoding.UTF8.GetString(second));
}

한쪽이 모르는 종류이거나 칸 이름이 하나라도 다르면 읽는 단계에서 오류가 나서 테스트가 실패한다. 실제 연결에서도 읽지 못한 메시지가 오면 양쪽 다 그 자리에서 연결을 끊는다. 개수를 31로 적어 둔 건 파일에서 줄이 빠져 시험이 줄어드는 걸 막으려는 것이다. 11편의 반대 방향 메시지(rmove, rkey 등)도 같은 파일에 줄을 더하고 이 숫자를 올리는 식으로 추가했다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.