본문으로 건너뛰기

dotgimon 제작기 29. 변조 펫 대전 잠금과 워치 잠금 동기화

·9 min read·29 / 32

DOTGIMON은 Expo/React Native로 만드는 LCD 방식 가상 펫 게임이다. 폰 앱이 있고, 같은 게임 엔진을 QuickJS(임베디드용으로 만든 작은 JavaScript 엔진)로 돌리는 애플워치·Wear OS 앱이 있다.

저장 데이터에는 변조 여부를 확인하는 도장을 찍는다. 도장은 저장 문자열을 기기에만 있는 비밀 열쇠로 계산한 HMAC-SHA256 값(열쇠 없이는 만들 수 없는 서명 값)이라, 누가 저장 파일을 고치면 도장이 맞지 않는다. 이날은 도장이 맞는지 확인해 정상·변조를 가르는 판정 결과를 실제 게임에 연결했다.

깨진 도장 열쇠는 새 열쇠로 바꿨다. 변조로 판정된 펫일 때와, 열쇠를 못 읽어 도장을 아직 확인하지 못했을 때는 DUAL을 막았다.

DUAL은 BATTLE 메뉴 안의 통신 항목이다. 블루투스로 붙는 근처 대전, 번호 코드로 중계 서버를 거쳐 잇는 코드 대전, 조그레스가 모두 여기서 열린다. 조그레스는 두 마리를 연결해 합체 진화시키는 기능이다.

대전이 잠긴 펫은 상대가 먼저 건 근처 통신 요청도 거절하게 했다. 폰의 잠금은 워치에 전달해서 워치도 같이 막았다.

깨진 열쇠를 덮어쓰지 않으면 확인이 끝나지 않는다

도장 열쇠는 SecureStore(Expo에서 키-값을 기기의 보안 저장소에 암호화해 두는 모듈)에 16진수 문자 64자(32바이트)로 둔다. 그 자리에 다른 값이 들어 있으면 어떻게 되는지부터 따져 봤다.

열쇠를 만드는 함수에는 "유효한 열쇠는 덮어쓰지 않는다"는 규칙만 있었다. 깨진 값은 읽어도 열쇠로 쓸 수 없고, 덮어쓰지도 않으니 확인이 영원히 미뤄진다.

기존 값이 유효한 열쇠일 때만 그대로 쓰고, 없거나 깨진 값이면 새로 만들어 덮어쓰게 바꿨다.

// src/storage/seal-key.ts
const existing = await store.getItemAsync(SEAL_KEY_ITEM) // 유효한 열쇠는 덮어쓰지 않는다 (다른 실행이 먼저 만들었을 수 있다)
if (existing !== null && keyFromHex(existing) !== null) return existing
// 없거나 깨진 값(16진 64자가 아님)이면 새로 만들어 덮어쓴다 — 깨진 채로 두면 확인이 영원히 미뤄진다
const key = await randomHex(SEAL_KEY_BYTES)
await store.setItemAsync(SEAL_KEY_ITEM, key, options())

열쇠가 바뀌면 기존 도장은 맞지 않는다. 그래도 괜찮았다. 도장을 설계할 때 상태별 판정을 표로 정리하면서 "열쇠가 없으면 기존 도장은 없는 것과 같다"고 이미 정해 두었기 때문이다. 끝없이 미뤄지는 것보다 판정이 나는 쪽이 낫다.

변조 펫도 키우기는 되고, 대전만 막았다

설계 때 정한 대로 변조 펫도 계속 키울 수 있고 대전만 못 한다. 판정은 SQLite 저장과 불러오기를 맡는 src/storage/ 코드(저장 계층)가 한다. 호스트(엔진을 돌리는 폰·워치 앱 쪽 코드)가 그 결과를 엔진에 넣는다. 엔진은 잠금 값 하나만 본다.

엔진 쪽 코드는 잠금 객체 하나와 메서드 하나로 끝난다.

// src/game/engine/link-lock.ts
export interface LinkLock { pets: readonly string[]; all: boolean }
 
linkLocked(this: Game): boolean {
  return this.linkLock.all || this.linkLock.pets.includes(this.petKey())
},

pets는 변조로 표시된 펫 키 목록이다. 펫 키는 세대:태어난 시각으로 만든 펫 한 마리의 식별자다. 펫이 끝나고 새 알로 다시 시작하면 펫 키가 바뀌므로 잠금이 자연스럽게 풀린다.

all은 열쇠를 못 읽어 도장 확인을 마치지 못했을 때 쓴다. 그때는 어느 펫이든 막는다.

DUAL에 들어가려 하면 LCD에 LOCKED 문구를 1.8초 동안 보여 주고 에러 효과음을 낸 뒤 홈 화면으로 돌아간다. 이 프로젝트는 LCD 화면 안에서는 모달을 쓰지 않아서 거절도 문구로만 알린다.

펫이 잠기면 홈 LCD 가운데에 뜨는 메뉴 이름 오른쪽 끝에 작은 !가 보인다. 기존 LCD 글꼴의 ! 글자를 그대로 써서 새로 그린 도트는 없다. 말풍선 문구나 이모트(펫의 행복·불행 감정 표시)가 떠 있을 때는 그리지 않는다.

근처 통신은 상대가 먼저 요청해 올 수도 있다. 잠긴 펫은 이 요청도 거절한다. 상대 화면에는 변조라는 사실이 보이지 않는다.

폰에서 막힌 펫이 워치로 대전에 나가면 도장이 소용없다

워치는 저장 도장을 판정하지 않는다. 도장 코드는 src/storage/에 있고, 워치 앱에 넣는 게임 엔진 JS 묶음(워치 번들)에는 들어가지 않는다. 그런데 워치에서도 코드 대전에는 나갈 수 있다. 폰에서 막힌 펫이 워치로 대전에 나가면 도장이 의미가 없다.

폰 → 워치 동기화 메시지에 linkLocked 필드를 하나 더했다. 폰만 보내고, 워치는 폰 말만 믿는다.

  • 워치는 받는 즉시 잠금을 따른다. 훈련·대전처럼 진행 중이라 폰에서 온 펫 상태 반영을 미루는 화면에서도 잠금은 바로 적용한다.
  • 워치는 받은 잠금을 자기 저장 파일에 남긴다. 폰 없이 워치만 다시 켜도 잠금이 풀리지 않는다.
  • linkLocked는 없어도 되는 필드고, 동기화 형식의 다른 부분은 그대로다. 그래서 이 필드가 없는 기존 메시지를 받아도 문제가 없다.

two-device.link-lock.test.ts에서는 가상 폰·워치 두 대로 폰의 잠금이 워치에 전달되는지, 워치가 재시작해도 유지되는지, 폰이 풀면 워치도 풀리는지 확인했다. 실기기에서는 확인하지 않았다.

무엇을 지키고 누구 말을 믿을지 정했다

"덮어쓰지 않는다"는 규칙에는 "무엇을"이 같이 있어야 했다. 지켜야 할 건 유효한 열쇠뿐인데, 값 전체를 지키면 깨진 값이 영원히 남는다.

판정하는 코드와 막는 코드도 나눴다. 저장 계층이 판정하고 엔진은 linkLocked() 하나만 본다. 덕분에 워치처럼 판정할 수 없는 기기도 막는 코드를 그대로 쓴다.

판정할 수 없는 기기는 판정할 수 있는 기기의 말을 따르고, 그 말을 저장한다. 받은 값을 메모리에만 두면 워치를 다시 켜는 것만으로 잠금이 풀린다.