@studyreadbook4ever with @FreakRYU 2026년 1학기 경희대학교 캡스톤디자인 수업에 진행한 프로젝트의 코드입니다. 저희도 많은 공개된 코드의 도움을 받았기에, Unlicense로 올립니다.
비트챗과 같은 상용 재난통신 프로그램들엔, ux적인 뻑뻑함이 너무 큽니다. 전체적으로 'relay storm'현상을 예방하기 위해 통신의 수를 줄이는 기조를 갖고 있는데 이 지점에서 메시지의 신뢰성이 떨어지는 문제(사칭공격의 가능성, ACK의 신뢰도 부족)들이 여럿 발견되어 저희 팀은 이 문제를 해결한 코드를 제작하였습니다.
오른쪽 Releases에서 최신 릴리스의 app-debug.apk를 다운로드하세요.
파일명이 한글이면 이상하리만치 꼬임.. 특히 linux pc를 직접 써보면서 이 지점이 크게 와닿아서 파일명은 영어인 점 양해 부탁드립니다.
기본적으로 mesh에 ble 기반으로 연결이 되면 broadcast와 DM을 할 수 있다. broadcast는 mesh의 모두에게 공개적으로 메시지를 보내는 것이고 DM은 mesh 안 1명에게 암호화된 DM을 보내 개인적인 문자를 주고받는 곳이다.
전연결은 3초 scan후 15초 scanoff 3초 scan 후 15초 scanoff를 계속 반복하는 구조이다. 기본적으로 ble 설계시엔, iOS가 ble를 연결하는 과정에선 uuid를 제외한 정보를 받지 않도록 설계되었다고 들어 우리가 제작하는 프로그램도 iOS와 표준을 맞춰야한다 생각해 드러내는걸 uuid와 표기되는 시스템 내 이름(scan response name)정도로 제한했다.
central → peripheral : characteristic write
peripheral → central : characteristic notification
이 구조 덕분에 하나의 GATT 연결이 사실상 full-duplex처럼 동작합니다.
그리고 gatt 연결을 마구마구 병렬로 둔다면, relay storm때문에 연결의 품질이 떨어지고 배터리 최적화가 망가지는 문제가 발생해 writequeue를 빡빡하게 관리한다. 경로는 fast와 reliable로 나뉘는데 강한 전송 보장을 위해 reliable 상태의 보낼 수 있는 지점을 철저하게 관리한다. pakcet type를 고르지 않고 그냥 무조건 어떤 종류의 패킷이든 노드에서 노드로 보내져야할 패킷은 reliable로 갈 수 있다면 reliable로감
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
위 코드를 기반으로 일단 reliable하게 보낼 수 있을땐 빠릿빠릿하게 보냄을 지향함으로써 좋은 활용성을 지향함.(bitchat android 레포는 이 지점에서 relay storm을 고려한, 활용성이 떨어지더라도 조심스러운 설계를 지향)
초기에 연결이 진행됐을 때 원래 mtu를 세세하게 연결의 품질에 따라 조절하는게 좋을거라고 생각했지만 요즘 블루투스는 mtu를 계속 244로 두고 해도 mtu서 보내지고 안 보내지는게 갈리는 경향은 별로 없고 오히려 이 지점서 억지로 조절하려고 하는거보다 무조건 client쪽에선 ‘mtu 244로 줘’ 식의 설계를 채택함(bitchat android repo) 한 편 이 mtu에 대한 협상이 실패할 정도로 통신품질(RSSI)이 안 좋을 시에는 한 번 실패하면 mtu 20으로 시스템이 돌아가도록 heuristic하게 설계했다. 통신의 품질에 대한 분석도 엄청나게 어렵고 심오한 개념이니까 차라리 1번 시도해보고 안 되면 말고 식이 합리적 설계로 보인다.
우선 패킷은 자체 메시 계층을 얹기 위한 구조이고, 이 패킷은 gatt 시스템의 mtu요구사항에 맞춰서 여러 프레임으로 분할되어 보내진다. 이쪽에서 직접적으로 관리해줄 부분은 없고(오히려 직접적으로 fragmentation이 손으로 관리된 형태로 패킷을 보내려고 하면 ble 연결이 터저버린다) 헤더는 다음과 같다.
💡byte 0:
패킷의 가장 앞 1바이트는 메시지의 성격과 전달 범위를 동시에 담는다. 이 바이트의 상위 2비트는 패킷의 종류를 나타내는 flag 영역으로 사용된다. 여기에는 브로드캐스트 메시지인지, 제어 메시지인지, ACK 메시지인지, 또는 개인 DM 메시지인지가 표시된다. 같은 패킷 처리 흐름 안에서도 이 flag 값에 따라 수신 노드는 메시지를 UI에 표시할지, peer 정보를 갱신할지, ACK 상태를 반영할지, 혹은 암호화된 DM으로 처리할지를 결정한다. 같은 byte의 하위 3비트는 TTL 값으로 사용된다. TTL은 패킷이 메시 네트워크 안에서 몇 hop까지 더 전달될 수 있는지를 제한하는 값이며, 노드를 하나 거칠 때마다 감소한다. 이를 통해 broadcast나 relay packet이 네트워크 안에서 무한히 순환하는 것을 방지한다. 상위 2비트와 하위 3비트를 제외한 중간 비트 영역은 현재 구조상 핵심 의미가 부여되지 않은 예약 영역에 가깝다.
byte 1:
두 번째 바이트는 프로토콜의 patch version을 나타낸다. 현재 Android 구현에서는 이 값이 2로 고정되어 있으며, 수신 측은 이 값을 통해 자신이 이해할 수 있는 SafeMesh 패킷 형식인지 확인한다. 즉, 이 필드는 패킷의 세부 포맷 버전을 식별하기 위한 값이다. 향후 헤더 구조나 payload 해석 방식이 바뀌는 경우, 이 version 값을 기준으로 이전 형식과 새 형식을 구분할 수 있다.
byte 2:
세 번째 바이트는 헤더의 전체 길이를 나타낸다. 현재 SafeMesh Android 패킷 헤더는 24바이트 고정 길이이므로 이 값은 24를 의미한다. 이 필드가 존재함으로써 수신 노드는 payload가 어디서부터 시작되는지 명확하게 알 수 있다. 또한 미래에 헤더가 확장되더라도, header length 값을 기준으로 기존 필드와 payload 영역을 분리할 수 있는 여지를 남긴다.
byte 3:
네 번째 바이트는 payload의 길이를 나타낸다. 이 값은 1바이트 unsigned integer로 표현되므로, 하나의 SafeMesh packet이 직접 담을 수 있는 payload 길이는 최대 255바이트로 제한된다. 여기서 말하는 payload는 BLE fragment 전체가 아니라, SafeMesh packet header 뒤에 붙는 실제 메시지 본문이다. 예를 들어 broadcast text, control gossip 정보, ACK bundle, private DM 암호문 등이 이 payload 영역에 들어간다.
byte 4..12:
다음 8바이트는 패킷이 생성된 시각을 밀리초 단위 timestamp로 저장한다. 이 값은 64비트 정수이며 big-endian 방식으로 배치된다. timestamp는 메시지의 생성 시점을 나타내기 때문에 ACK의 지연 시간 계산, pending message의 만료 판단, retry window 관리, UI 정렬 등에 활용될 수 있다. 특히 BLE mesh처럼 연결이 간헐적이고 패킷이 늦게 도착할 수 있는 환경에서는, 수신 시각과 생성 시각을 구분할 수 있다는 점이 중요하다.
byte 12..16:
이 4바이트는 송신 노드의 sender_id를 담는다. sender_id는 메시지를 만든 노드를 식별하는 값이며, broadcast, ACK, control gossip, private packet 처리에서 공통적으로 사용된다. SafeMesh에서는 sender_id 하나만으로 메시지 전체의 고유성을 판단하지 않고, 뒤에 나오는 epoch와 sequence 번호까지 함께 묶어 멱등성 키를 만든다. 따라서 동일한 sender가 여러 메시지를 보낼 때도 각 패킷을 안정적으로 구분할 수 있다.
byte 16..18:
이 2바이트는 epoch 값을 나타낸다. epoch는 sequence 번호만으로는 구분하기 어려운 실행 세대나 송신 세션의 변화를 구분하는 보조 식별자 역할을 한다. 예를 들어 노드가 재시작되거나 sequence 관리 범위가 바뀌는 상황에서, 같은 sender_id와 비슷한 sequence 번호가 다시 나타나더라도 epoch가 다르면 별도의 메시지 흐름으로 구분할 수 있다. 이 값 역시 big-endian 방식으로 저장된다.
byte 18..24:
마지막 6바이트는 sequence 번호를 담는다. 일반적인 32비트 sequence보다 더 넓은 48비트 공간을 사용하므로, 한 노드가 장시간 많은 메시지를 생성하더라도 sequence 충돌 가능성을 낮출 수 있다. 이 sequence는 sender_id, epoch와 함께 메시지의 멱등성 키를 구성한다. 수신 노드는 이 조합을 이용해 이미 처리한 패킷인지 확인하고, 같은 메시지가 여러 BLE 경로를 통해 반복 도착하더라도 중복 표시나 무한 재전송을 방지한다. sequence 역시 big-endian 방식으로 배치된다.
브로드캐스트는 메시 내 모두에게 보내는 공개 메시지이다. android에선 broadcast패킷을 생성해 모든 ble link로 전송한다. 실제 네트워크의 왕복을 기다리진 않고 ack이 받은 상대로부터 오면 그거에 대해 그때그때 ack 온거를, 보낸 사용자에게 보여주는 구조를 취한다. 이 때 무조건 즉시 모든 메시지를 모두에게 뿌리는 것이 아닌, 최근 전달한 패킷의 기록을 멱등성을 기반해서 과거에 보낸 적이 있는 메시지에 대해서는 전파를 안 해준다. 그리고 이 broadcast 메시지는 받고 60초간 ‘새로 붙는 대상’에게 보낼 채비가 된다.
DM은 특정 peer을 대상으로 하는 암호화된 메시지이며, 시스템 내에서 fingerprint 기반의 세션수립 과정과 암호화 과정을 거친다. 이 암호화를 통해 dm본문이 암호화되어 private data 패킷으로 전송된다. private data 내부에 있는 내용들이 외부에는 AEAD 암호문으로만 보인다.
결론: 코드의 연결 전반부는 기본적으로 빠르게 연결이 잘 붙도록 느슨하게 설계된 구조이다. mesh엔 모두가 참여할 수 있어야 하는 것이 원칙이기에 일단 Just Works 기반으로 잘 붙도록 설계를 진행하였다. 연결 이후에 패킷을 보내는 부분에선 '오류'에 대한 내성이 큰 문제였다. DM은 android keystore이 활용되며 이를 기반으로 보안이 빡빡하게 관리되어 중간자공격이 예방된다. 또한 dm 세션인증 과정에 있는 fingerprint와 별개의 '시스템이 점지해주는' 4자리 태그를 둬 사칭공격을 원천 차단한다.
- ForegroundService 기반이여야
- gatt가 비대칭인거 고려
- stale callback을 막기 위해 TxQueue가 관리됨(직렬화)
- 새 link가 기존의 ack/control/retry/user outbox/보안key 전부 flush→ 이렇게 안 하니까 가장 큰 문제가 초기화뿐 아니라 앱을 지웠다가 다시 깔아도 과거의 흔적이남음;;
- 멱등성을 시스템 안에서 챙겨주면서, 동시에 패킷에 대한 폐기를 조심해야함.< 어차피 시스템이 중복된 패킷 많이 받아도 그건 멱등성으로 챙겨
- DM race 현상<claude,codex를 활용하면서 가장 애를 많이 먹은 부분인데, DM의 오류로 인한 pending이 ble 세션 전체에 race condition을 유발하는 현상을 고치는게 엄청 어려웠다.
- 가끔 인증보다 dm data plane이 먼저 목적지에 도착하는 경우가 생기는데 여기서 dm이 세션 통째로 터지는 버그가 있어서.. 이건 각 노드들에 private data에 대한 일시적인 보관을 구현하여 해결했다.
- 시스템 내부의 fingerprint는 노출이 되면 안 되고 그거와 별개지만 이어져있는 시스템 tag를 통해 사칭공격을 예방해야한다→원래 사칭공격 예방책으로 설계하던 PASSKEY 아이디어 폐기
PASSKEY→tag 이미그레이션의 이유: 5월 29일 교수님과 회의하던 도중 교수님께서 ‘굳이 PASSKEY 필요없잖아’ 라고 하신 의견을 들었다. 설계를 할 당시에는 tag 보다 더 강한 보안을 주자고 잘못 판단을 했는데 어차피 시스템 내부의 fingerprint가 있고 굳이굳이/ 차라리 PASSKEY가 현실에서 유출됨으로써 공격이 일어나는 문제도 있고 모순적인 아이디어라는 생각이 들어 폐기했다.