
CMF Watch Pro 2용 역설계된 BLE 프로토콜로, GATT 레이아웃, AES-128-CBC 암호화 명령 프레임, 인증 핸드셰이크 및 대체 컴패니언 앱 개발을 위한 건강 데이터 동기화를 문서화합니다.
비공식. 이 문서는 CMF Watch Pro 2 (CMF by Nothing)의 Bluetooth Low Energy (BLE) 프로토콜을 대체 컴패니언 앱을 위해 리버스 엔지니어링으로 재구성한 것입니다. Nothing/CMF와 제휴하거나 보증하지 않습니다. 사용에 따른 책임은 본인에게 있습니다.
프레임 헤더와 opcode의 모든 멀티바이트 정수는 빅엔디언입니다. 명령 페이로드 내부의 정수는
별도로 명시되지 않는 한 리틀엔디언입니다(이는 기기 펌웨어를 반영함) — 예외(GOALS_SET, GPS_PUSH,
대량 전송 오프셋/길이)에 주의하세요.
아래의 모든 명확하지 않은 주장에는 확립 방법이 태그로 표시됩니다:
이후 섹션이 이전 섹션을 수정하는 경우, 이전 텍스트는 삭제하지 않고 포인터와 함께 유지됩니다 — 시도되고 반증된 판독값을 아는 것은 다음 사람이 같은 우회로를 피하게 해줍니다.
모든 캡처의 테스트 기기: CMF Watch Pro 2-5485, 펌웨어 1.0.0.73, 일련번호 CI04102520008192,
MCU Actions ATS3089C (Cortex-M4), 화면 466×360.
휴대폰이 GATT 클라이언트이고, 시계가 주변 기기로 CMF Watch Pro 2-XXXX(4자리 16진수)로
광고합니다.
| 용도 | 서비스 | 특성 | 속성 |
|---|---|---|---|
| 명령 쓰기 | 0000fff0-0000-1000-8000-00805f9b34fb | 0000fff2-… | Write |
| 명령 알림 | 0000fff0-… | 0000fff1-… | Notify |
| 셸 쓰기 (AT) | — | 77d4ff01-2fe2-2334-0d35-9ccd078f529c | Write |
| 셸 알림 (AT) | — | 77d4ff02-… | Notify |
| 대량 데이터 쓰기 | — | 02f00000-0000-0000-0000-00000000ffe1 | Write |
| 대량 데이터 알림 | — | 02f00000-…ffe2 | Notify |
각 CCCD(00002902-…)에 01 00을 써서 알림을 활성화합니다. 명령 채널(fff1/fff2)은
아래의 프레임 프로토콜을 전달합니다. 셸 채널(77d4…)은 일반 AT 스타일 텍스트(예:
AT GETSECRET; §14 참조)를 전달합니다. 데이터 채널(02f0…)은 명령 채널의 제어 opcode로
조정되는 대용량 바이너리 블롭(워치페이스, 펌웨어, AGPS)을 전달합니다.
서비스 UUID — 시계는 약 10개의 기본 서비스를 광고합니다. 실제 기기에서 열거됨:
0xfff0 (명령), 0x180f (배터리), 0x180a (기기 정보), 0xefe7, 0xffd0,
02f00000-…ffe0 및 02f00000-…fe00 (데이터), 77d4e67c-2fe2-2334-0d35-9ccd078f529c (셸 /
페어링), e49a3001-f69a-11e8-8eb2-f2801f1b9fd1, f48a23c0-f69a-11e8-8eb2-f2801f1b9fd1.
⚠️ 셸 서비스 UUID는
77d4e67c-…이며77d4ff00-…이 아닙니다. 이 문서의 이전 개정판에서는 서비스가 특성(77d4ff01/77d4ff02, §14)의ff00접두사를 공유한다고 가정했지만 실제로는 그렇지 않습니다(적어도 확인된 기기에서는 그렇지 않음 — freethinkel/fmc의 발견, §출처 참조). 특성 UUID는 변경되지 않습니다.77d4e67c가 기기 간에 안정적인지 확인되지 않았습니다 — 하드코딩보다는 열거하세요.
🌐 Web Bluetooth 참고. Chromium은 필터링되지 않은
getPrimaryServices()호출에서도 페이지가optionalServices에 나열한 서비스만 발견합니다 — 3개의 서비스를 나열한 페이지는 3개만 보는 반면,chrome://bluetooth-internals(Chrome 자체 C++ 레이어, 범위 제한 없음)는 10개 모두를 표시합니다. 브라우저 클라이언트를 작성하는 경우 위의 모든 UUID를 미리 나열해야 합니다. 그렇지 않으면 분명히 존재하는 서비스로 페어링이 실패합니다. Firefox/Safari에는 Web Bluetooth가 없습니다. 사용자 제스처 + HTTPS/localhost가 필요합니다.
✅ 전체 실제 세션이 단일 명령 채널에서 실행되었습니다 — 160초의 과도한 사용 캡처 동안 명시적인 OTA/워치페이스 전송 중을 제외하고 데이터/펌웨어 또는 셸 채널에서 트래픽이 없었습니다.
0xF5)모든 명령 채널 메시지는 하나 이상의 11바이트 헤더 프레임으로 감싸집니다:``` +------+-----------+--------+-------------+-------------+--------+-------------------+ | 0xF5 | chunkLen | cmd1 | chunkCount | chunkIndex | cmd2 | chunk bytes … | | 1 B | 2 B (BE) | 2 B BE | 2 B BE | 2 B BE | 2 B BE | chunkLen bytes | +------+-----------+--------+-------------+-------------+--------+-------------------+ __________________________ 11-byte header ____________________________/
- `cmd1`/`cmd2`는 함께 **opcode**를 구성합니다(§6 참조). 🔎 공식 앱의 프레임 빌더(`C6117b.m30831g`)에서 확인됨.
- `chunkCount` = 이 명령의 총 청크 수; `chunkIndex`는 **1부터 시작**합니다.
- `chunkLen` = 이 프레임에서 `chunk`의 바이트 수.
- 단일 BLE 쓰기는 링크 MTU에 의해 분할될 수 있습니다. 수신기는 원시 바이트를 버퍼링하고 완전한 프레임을 다시 추출합니다. 대용량 페이로드는 여러 청크(동일한 `cmd1/cmd2`, 증가하는 `chunkIndex`)로 분할되어 순서대로 재조립됩니다.
### Opcode 규칙 (✅ 와이어에서 확인됨)
- `cmd1 = 0xFFFF`: `cmd2`가 `0x80xx`/`0x90xx` = 폰→워치(요청/설정); `0x00xx`/`0xa0xx` = 워치→폰(응답). 쌍은 하위 바이트로 매칭됩니다(`0x9055`↔`0xa055`, `0x8051`↔`0x0051`).
- 기능별 `cmd1`: `cmd2` 접미사 = `0x0001` **SET**, `0x0002` **GET**, `0x0003` **ACK**.
### 청크 본문
각 청크의 본문은 `payloadPiece ‖ CRC32_LE(payloadPiece)`(4바이트 CRC, 리틀엔디언, zlib/IEEE)입니다. 명령이 **암호화된** 경우(§3 참조), 전체 `payloadPiece ‖ CRC`가 AES-128-CBC/PKCS7로 암호화되고 해당 암호문이 프레임 `chunk`가 됩니다.
**평문 특이점:** 평문 opcode의 경우 워치가 `chunkLen`에 4바이트 CRC를 *포함하여 계산*하지만 이를 **전송하지는 않습니다**. 따라서 평문 프레임을 디코딩할 때 실제 데이터 길이는 `chunkLen − 4`입니다. (암호화된 프레임은 평소처럼 암호문 내부에 CRC를 포함합니다.)
청크 크기(암호화된 청크가 AES 블록 경계에 맞도록), `maxWrite = mtu − 3`:
- 암호화: `floor((maxWrite − 11) / 16) * 16 − 4 − 1`
- 평문: `maxWrite − 11 − 4 − 1`
✅ 관찰된 모든 암호화 프레임 `chunkLen` 값은 16의 배수였습니다(블록 정렬 유지됨).
---
## 3. 암호화 기본 요소
- **AES-128-CBC** with **PKCS7** 패딩 및 **고정 IV**(펌웨어 `CmfCharacteristic.AES_IV`에서):
`50 51 52 53 54 55 56 57 60 61 62 63 64 65 66 5A`.
- **CRC32** (zlib/IEEE), 4바이트 리틀엔디언으로 출력.
- **SHA-256** over the concatenation of parts.
키 파생:```
authkey = SHA256( rnd1 ‖ rnd2 ‖ secret )[0..16] // persisted across sessions
sessionKey = SHA256( nonce ‖ authkey )[0..16] // per connection
secret = 16바이트 기기 비밀값(시계에서 셸 명령어 AT GETSECRET → GETSECRET:<32-hex>,OK로 획득 가능).rnd1 = 휴대폰이 선택한 16바이트 난수; rnd2 = 시계에서 온 16바이트 난수.nonce = 시계의 nonce 응답에서 온 바이트.키가 설정된 후, 모든 명령 채널 프레임은 AES로 암호화되며, §5에 나열된 평문 opcode는 예외입니다.
✅ 두 파생 방식 모두 검증됨: 루팅된 휴대폰의 ntwatch.db에서 복구한 authkey가 캡처된 rnd1/rnd2/secret에서 파생된 값과 일치했고, 캡처된 nonce에서 재현한 sessionKey가 실시간 프레임을 복호화합니다.
두 진입 경로는 동일한 nonce/confirm 꼬리 부분을 공유합니다.
phone → (shell) AT GETSECRET watch → (shell) GETSECRET:<32hex>,OK phone: rnd1 = random16 ; signed1 = SHA256(rnd1 ‖ secret) phone → AUTH_PAIR_REQUEST (plaintext) payload = rnd1(16) ‖ signed1(32) // 48 B watch → AUTH_PAIR_REPLY (plaintext) payload = rnd2(16) ‖ signed2(32) // 48 B phone verifies signed2 == SHA256(rnd2 ‖ secret) phone: authkey = SHA256(rnd1 ‖ rnd2 ‖ secret)[0..16] → set crypto key = authkey phone → AUTH_PHONE_NAME (encrypted) payload = 0xA5 ‖ model(UTF-8) // e.g. "CMF Watch Pro 2" watch → AUTH_WATCH_MAC (encrypted) phone → AUTH_NONCE_REQUEST (encrypted) payload = 0xA5 watch → AUTH_NONCE_REPLY (encrypted) payload = nonce phone: sessionKey = SHA256(nonce ‖ authkey)[0..16] → set crypto key = sessionKey phone → AUTHENTICATED_CONFIRM_REQUEST (encrypted) payload = 0xA5 watch → AUTHENTICATED_CONFIRM_REPLY (encrypted) → state = Initialized
`AUTH_FAILED (0xFFFF,0xA061)` 또는 서명 불일치가 발생하면 인증이 실패합니다.
### 4.2 재연결 (authkey 이미 알려진 경우)```
set crypto key = authkey (persisted)
phone → AUTH_PHONE_NAME (encrypted) payload = 0xA5 ‖ model
watch → AUTH_WATCH_MAC (encrypted)
phone → AUTH_NONCE_REQUEST (encrypted) payload = 0xA5
watch → AUTH_NONCE_REPLY (encrypted) payload = nonce
sessionKey = SHA256(nonce ‖ authkey)[0..16] → set crypto key = sessionKey
phone → AUTHENTICATED_CONFIRM_REQUEST (encrypted) payload = 0xA5
watch → AUTHENTICATED_CONFIRM_REPLY (encrypted) → Initialized
✅ 재연결 순서(셸 트래픽 없음)가 실제 캡처에서 그대로 유지되는 것이 관찰되었습니다.
⚠️→✅ 데이터 조회 전에
TIME이 필수입니다.Initialized이후, 워치는 세션에서TIME (FFFF 8004)가 전송될 때까지BATTERY,SERIAL_NUMBER_GET, 또는ACTIVITY_FETCH_*핸드셰이크에 응답하지 않습니다 — 없으면 수신되지 않은FIRMWARE_VERSION_RET만 도착하고 나머지는 모두 타임아웃됩니다. ✅ 실기기(Pixel 8a)로 확인:TIME없이 세 개의 GET을 전송 → 펌웨어 응답만 수신;TIME을 먼저 전송 → 배터리 및 시리얼 응답 시작.
권장 2단계 순서: TIME → FIRMWARE_VERSION_GET → SERIAL_NUMBER_GET →
BATTERY (0xA5) → 설정 푸시 → 건강 동기화(§8).
대부분의 설정에는 별도의 "읽기" 명령 코드가 없습니다. *_GET(cmd2 = 0x0002, 페이로드
0xA5)을 전송하면 워치가 현재 값을 담은 SET 명령 코드(cmd2 = 0x0001)로 응답합니다.
SET 명령은 cmd2 = 0x0003과 빈 본문으로 확인됩니다.
키가 설정되면 프레임은 AES로 암호화되지만, 다음 명령 코드는 항상 평문으로 전송됩니다:
AUTH_PAIR_REQUEST (FFFF 8047), AUTH_PAIR_REPLY (FFFF 0048)DATA_CHUNK_WRITE_WATCHFACE (FFFF 9064), DATA_CHUNK_WRITE_FIRMWARE (FFFF 9042),
DATA_CHUNK_WRITE_AGPS (FFFF 905F)