블로그로 돌아가기
Security10 min read

페어링 흐름

이 글에서는 두 대의 PlainApp 기기가 처음으로 신뢰를 설정하는 방법을 설명합니다. 즉, 기기들이 서로를 어떻게 발견하고, 키를 교환하며, 이후 모든 채팅 메시지·파일 전송·프레전스 핑을 암호화하는 데 사용되는 공유 ChaCha20 트랜스포트 키에 어떻게 도달하는지를 다룹니다. 이 키를 사용하는 채팅 및 채널 아키텍처는 별도의 Chat Architecture 글에서 설명합니다.

목차

페어링이 필요한 이유 {#why-pairing-exists}

PlainApp은 중앙 계정 서버가 없습니다. 따라서 기기들은 통신할 수 있기 전에 두 가지 질문에 답해야 합니다:

  1. "당신은 누구입니까?" — 모든 기기는 첫 실행 시 안정적인 clientId를 생성합니다 (Ed25519 키 자료에서 파생된 13문자 ID). 이것은 라우팅, 프레전스, 채널 멤버십에 사용되는 유일한 식별자입니다.
  2. "당신을 신뢰해도 됩니까?" — 서버가 신원을 보증하지 않으므로, 피어가 주장하는 대상이 맞는지 확신하는 유일한 방법은 사람이 양쪽 기기에서 페어링을 확인하고, 프로토콜이 암호화 서명을 검증하는 것입니다.

페어링은 단 하나의 산출물을 만듭니다: 데이터베이스의 DPeer 행으로, status="paired", ChaCha20 key(공유 트랜스포트 비밀키), 그리고 피어의 Ed25519 public_key(이후 메시지 서명 검증용)를 포함합니다. 채팅 하위 시스템의 이후 모든 프로토콜은 이 두 필드가 존재한다고 가정합니다.

Diagram 1
1

신뢰 모델과 암호학 {#trust-model--cryptography}

페어링은 두 가지 독립적인 암호학 프리미티브를 사용합니다:

프리미티브목적라이프사이클
Ed25519 (서명)페어링 요청과 응답을 인증합니다. "이것이 정말로 전송을 주장한 기기로부터 왔음"을 검증하고, 재생을 방지하기 위해 타임스탬프를 바인딩합니다.서명 키는 기기의 장기 신원 키입니다. 공개 키 부분은 DPeer.public_key로 저장되며, 이후 PeerChatParser.decrypt가 모든 채팅 메시지 서명을 검증하는 데 사용합니다.
X25519 방식 ECDH (키 합의)공유 비밀을 생성하여 ChaCha20 트랜스포트 키로 만듭니다. 두 기기는 이 비밀을 전혀 전송하지 않고도 동일한 비밀을 계산합니다.페어링 세션마다 임시 키 쌍을 생성하며, 공유 키 계산 직후 폐기합니다. 결과 32바이트 비밀은 DPeer.key로 저장되어 페어링이 유지되는 동안 재사용됩니다.

여기에는 PIN도, QR 코드도, 대역 외 코드도 없습니다. 신뢰는 다음 방식으로 설정됩니다:

  1. 사람이 응답자 기기에서 Accept를 누릅니다(사용자가 "네, 이 기기와 페어링하고 싶다"고 단언하는 것입니다).
  2. 양쪽 모두 요청/응답에서 상대의 Ed25519 서명을 검증합니다(응답자가 세션을 시작한 동일한 기기와 통신 중임을 증명하고, 그 반대도 마찬가지입니다).
  3. 두 메시지에 ±5분 타임스탬프 윈도우를 적용합니다(과거에 캡처된 핸드셰이크의 재생을 방지합니다).

이 비대칭성이 중요합니다: 사람 확인 한 번으로는 중간자 공격(MITM)에 취약합니다 (공격자가 양쪽을 각각 페어링할 수 있습니다). ECDH 공개 키에 대한 Ed25519 서명이 이를 방지합니다 — 응답자는 요청이 세션을 시작한 동일한 Ed25519 키로 서명되었음을 검증하고, 그 반대도 마찬가지이므로, MITM은 장기 서명 키까지 통제하지 않는 한 자신의 ECDH 키를 투명하게 대체할 수 없습니다.

컴포넌트 맵 {#component-map}

모든 페어링 코드는 discover/ 패키지에 있습니다(chat/peer/pair/가 아닙니다):

Diagram 2
2

파일 위치

컴포넌트경로 (shared/src/commonMain/kotlin/com/ismartcoding/plain/ 하위)
LANDiscoverManagerdiscover/LANDiscoverManager.kt
PairingCorediscover/PairingCore.kt
PairingInitiatordiscover/PairingInitiator.kt
PairingResponderdiscover/PairingResponder.kt
PairingSecuritydiscover/PairingSecurity.kt
PairingSessionStorediscover/PairingSessionStore.kt
PairingPeerStorediscover/PairingPeerStore.kt
PairingMessengerdiscover/PairingMessenger.kt

발견 단계 {#discovery-phase}

페어링이 일어나려면 먼저 기기들이 서로를 찾아야 합니다. LANDiscoverManager는 앱이 시작되면 지속적으로 실행됩니다:

Diagram 3
3

지시형 발견이 암호화되는 이유

브로드캐스트 DISCOVER는 민감한 정보를 노출하지 않습니다(fromId=clientId만), 따라서 LAN의 모든 기기가 보아도 무방합니다. 그러나 지시형(directed) 변형은 한 기기가 다른 기기의 clientId를 이미 알고 있을 때(예: 페어링은 되었으나 피어의 IP가 변경된 경우) 그 기기를 깨우고 싶을 때 사용됩니다. 대상 clientId를 피어의 공유 키로 암호화하면 다음과 같은 효과가 있습니다:

  • 올바른 피어는 toId를 복호화하고 자신을 인식한 뒤 응답할 수 있습니다.
  • LAN의 다른 모든 기기는 암호문만 보게 됩니다 — 발신자가 어떤 clientId에 도달하려 하는지 열거할 수 없습니다.

이는 작지만 실질적인 프라이버시 속성입니다: 수동적 LAN 관찰자는 누가 누구와 페어링되었는지에 대한 그래프를 구성할 수 없습니다.

응답의 Aware 플래그

DISCOVER_REPLY는 awareSupportedawareRunning을 전달합니다. 이 값들은 데이터베이스에 저장되지 않습니다PeerCacher의 메모리에 저장되며, 매 응답마다 (그리고 BLE 스캔 응답의 serviceData에서도) 갱신됩니다. 트랜스포트 계층은 이 값을 참조하여 Wi-Fi Aware 링크를 시도할지, 아니면 곧바로 BLE로 넘어갈지 결정합니다.

페어링 시퀀스(정상 경로) {#pairing-sequence-happy-path}

두 기기가 동일한 LAN에 있고 사용자가 페어링을 수락할 때의 종단 간 흐름입니다:

Diagram 4
4

양쪽 모두 피어를 독립적으로 저장하는 이유

초기자(9단계)와 응답자(7단계) 모두 상대 기기에 대해 PairingPeerStore.save(...)를 호출합니다. 이는 의도적인 것입니다: 각 기기는 상대의 clientId로 키가 지정된 DPeer 행을 갖게 되며, 자신만의 공유 ChaCha20 키 사본과 상대의 Ed25519 공개 키를 포함합니다. 중앙 레지스트리는 없습니다 — 페어링은 대칭적이고 자기 완결적입니다.

응답자가 먼저 키를 계산하는 이유

응답자의 acceptPairingRequest는 수락 즉시 공유 키를 계산하여 영속화합니다. 이는 응답자가 응답이 초기자에게 돌아가기 전에 암호화된 트래픽을 수신하기 시작할 수 있음을 의미합니다. 응답이 전송 중에 손실되더라도 응답자는 여전히 페어링된 상태입니다 — 초기자만 재시도하면 됩니다.

키 교환 상세 {#key-exchange-details}

페어링의 암호학적 핵심은 표준 X25519 방식 ECDH 키 합의이지만, 그 위에 인증을 위한 Ed25519 서명이 계층화되어 있습니다.

Diagram 5
5

서명이 실제로 보호하는 것

서명된 페이로드(toSignatureData())는 안정적인 요청 필드들의 정규 연결입니다: fromId, fromName, port, deviceType, ecdhPublicKey, signaturePublicKey, timestamp, ips입니다. **ecdhPublicKey**를 장기 signaturePublicKey와 함께 서명함으로써, 프로토콜은 임시 키를 기기 신원에 바인딩합니다. 공격자는 서명을 무효화하지 않고서는 전송 중인 ECDH 공개 키를 자신의 것으로 대체할 수 없으며, 장기 Ed25519 키를 통제하지 않는 한 서명을 위조할 수도 없습니다.

이것이 중간자 공격을 무력화하는 지점입니다: 공격자가 두 기기 간의 모든 패킷을 릴레이하더라도, 암호화된 트래픽을 읽을 수 없고(어느 쪽의 ECDH 개인 키도 없으므로) 자신의 ECDH 키로 대체할 수도 없습니다(서명이 깨지므로).

응답자 수락/거절 흐름 {#responder-acceptdecline-flow}

응답자 측은 PAIR_REQUEST가 도착하면 UI 다이얼로그를 표시합니다. 사용자는 수락하거나 거절할 수 있습니다.

Diagram 6
6

응답자가 수락 즉시 PairingSuccessEvent를 발생시키는 이유

응답자의 acceptPairingRequestPairingPeerStore.save(...)를 호출하고 응답을 보내기 전에 PairingSuccessEvent를 발생시킵니다. 이는 의도적입니다: 응답이 초기자에게 도달하지 못하더라도(네트워크 글리치), 응답자는 여전히 페어링된 상태입니다 — 초기자가 다음에 페어링을 시도할 때 응답자의 이미 존재하는 DPeer 행이 프레전스 시스템에 의해 발견됩니다. 초기자는 단순히 재시도하면 되고, 응답자는 다시 확인할 필요가 없습니다.

취소 흐름 {#cancel-flow}

어느 쪽이든 진행 중인 페어링을 취소할 수 있습니다.

Diagram 7
7

DPairingCancelLAN 유니캐스트로만 전송됩니다(초기자는 발견 단계에서 응답자의 IP를 이미 알고 있음). 반면 거절 응답은 LAN과 BLE 양쪽 모두로 전송되는데, 응답자는 초기자가 어느 트랜스포트에서 도달 가능한지 확신할 수 없기 때문입니다.

이중 채널 전달(LAN + BLE) {#dual-channel-delivery-lan--ble}

응답자가 DPairingResponse를 보낼 때 LAN과 BLE 양쪽에 동시에 보냅니다. 초기자는 첫 번째 사본을 수락하고 중복은 조용히 폐기합니다.

Diagram 8
8

BlePairingSessionStore가 존재하는 이유

PAIR_REQUEST가 BLE로 도착하면, 응답자는 초기자의 LAN IP를 알지 못합니다 — BLE MAC 주소만 알고 있습니다. BlePairingSessionStorepeerId → MAC 매핑을 유지하여, 필요 시 응답을 BLE를 통해 되돌려 보낼 수 있게 합니다. 이는 작은 메모리 내 임시 맵으로, BLE로 라우팅된 요청에만 채워지며 응답이 전송되면 즉시 비워집니다.

세션 및 피어 저장 {#session--peer-storage}

두 저장소가 페어링에 참여하며, 라이프사이클이 매우 다릅니다:

Diagram 9
9

clientId만 영속화되는 식별자인 이유

Android는 모든 연결마다 BLE MAC 주소를 무작위화하므로, 이를 저장하는 것은 쓸모가 없습니다. clientId는 기기의 장기 Ed25519 키 자료에서 파생되므로 다음과 같은 특성이 있습니다:

  • 안정적 — 앱 재설치에도 변하지 않습니다(키는 플랫폼 키스토어에 있음).
  • 자기 인증적clientId를 주장하는 누구든 해당 Ed25519 개인 키를 보유하고 있음을 증명해야 합니다(모든 서명된 메시지에서 검증됨).
  • 프라이버시 보호 — 8바이트 SHA-256 접두사(shortId)만이 발견을 위해 BLE로 브로드캐스트됩니다. 전체 clientId는 실제로 페어링한 기기에만 공개됩니다.

보안 속성 {#security-properties}

속성달성 방법
기밀성모든 트랜스포트는 ECDH로 파생된 공유 키로 ChaCha20 암호화됩니다. 키는 페어링 이후 두 기기 밖으로 나가지 않습니다.
인증모든 서명된 메시지(페어링 요청/응답, 채팅 createChatItem, 채널 invite/update/kick)는 발신자의 저장된 public_key에 대해 Ed25519로 검증됩니다.
무결성Ed25519 서명은 전체 요청 본문을 포함합니다; 어떤 변조도 서명을 무효화합니다.
재생 저항±5분 타임스탬프 윈도우(PeerChatParserPairingSecurity가 적용). ChatMessageReceiver.seenSignatures가 윈도우 내 중복을 제거합니다.
중간자 공격 저항임시 ECDH 공개 키가 장기 Ed25519 공개 키와 함께 서명됩니다. MITM은 서명을 깨뜨리지 않고 자신의 ECDH 키를 대체할 수 없습니다.
전방 비밀성(제한적)ECDH 키 쌍은 페어링 세션마다 임시입니다. 나중에 장기 Ed25519 키가 노출되어도 과거 트래픽이 복호화되지 않습니다(공유 키도 여전히 필요 — 하지만 ECDH 개인 키와 저장된 DPeer.key가 모두 지워지면, 과거 캡처를 복호화할 수 없습니다).
서비스 거부 저항onDatagram이 모든 메시지를 try/catch로 감싸, 잘못된 패킷이 발견 수신자를 죽이지 않습니다. PeerCircuitBreaker는 2회 실패 후 30초간 불안정한 트랜스포트를 건너뜁니다.
프라이버시(지시형 발견)LANDiscoverManager.discoverSpecificDevice가 대상 clientId를 피어 키로 암호화합니다 — 수동적 LAN 관찰자는 누가 누구와 페어링되었는지 열거할 수 없습니다.
신원 안정성clientId는 플랫폼 키스토어의 장기 Ed25519 키 자료에서 파생됩니다 — 재설치에도 안정적이고, 자기 인증적이며, 전화번호나 이메일에 종속되지 않습니다.

페어링이 방어하지 않는 것

  • 물리적 기기 탈취. 공격자가 페어링된 기기에서 root 권한을 얻으면, 데이터베이스에서 공유 키를 읽고 해당 피어를 가장할 수 있습니다. 공유 트랜스포트 키에 대한 하드웨어 기반 키스토어 강제는 없습니다(SignatureHelper를 통한 Ed25519 서명 키에만 적용됨).
  • 능동적 릴레이 공격. BLE와 LAN 트래픽을 동시에 릴레이하여 두 기기가 서로 페어링하고 있다고 믿게 만드는 공격자가 이론적으로 중간에 위치할 수 있습니다 — 하지만 ECDH 공개 키에 대한 Ed25519 서명 때문에 트래픽을 읽을 수는 없고 릴레이만 할 수 있습니다. 이는 숫자 비교 없는 블루투스 페어링과 동일한 트레이드오프입니다.
  • 네트워크 수준 차단. 방화벽이 UDP 멀티캐스트를 차단할 수 있고, BLE는 재밍될 수 있으며, Wi-Fi Aware는 사용 불가능할 수 있습니다. 시스템은 우아하게 저하됩니다(페어링된 피어에 대해서는 BLE가 보장된 폴백)지만, 적극적으로 적대적인 네트워크를 우회할 수는 없습니다.

상태 머신 요약 {#state-machine-recap}

Diagram 10
10

더 읽어보기

  • Chat Architecture — 공유 키의 용도: 피어 채팅 송수신, 채널 fan-out, 프레전스, 파일 다운로드.
  • apitest/groups/discovery.sh — 발견 및 페어링 API 표면을 종단 간으로 테스트하는 실행 가능한 테스트 계획.