Zanim Baby Monitor Timmy zacznie przesyłać dźwięk i obraz, dwa urządzenia muszą się odnaleźć i zaufać sobie nawzajem. Ten etap parowania jest najważniejszym momentem całego procesu. Wyjaśniam tutaj, jak Timmy paruje urządzenia, jaka kryptografia za tym stoi i dlaczego osoba atakująca w pobliżu nie może przejąć połączenia niezauważenie.
Problem: skąd moje urządzenie wie, z kim rozmawia?
Gdy dwa urządzenia łączą się po raz pierwszy, kluczowe pytanie brzmi: czy urządzenie A naprawdę rozmawia z urządzeniem B, czy ktoś znajduje się pomiędzy nimi? W kryptografii nazywa się to atakiem typu man-in-the-middle (MITM).
Timmy rozwiązuje ten problem dzięki wymianie kluczy Elliptic Curve Diffie-Hellman (ECDH) przez Firebase, połączonej z wizualną weryfikacją przez użytkownika.
Poniższy diagram pokazuje cały proces parowania w skrócie:
sequenceDiagram
autonumber
participant A as 📱 Device A
participant F as ☁️ Firebase
participant B as 📱 Device B
Note over A,B: Phase 1 — Discovery
A->>A: Generate ECDH key pair (P-256)
B->>B: Generate ECDH key pair (P-256)
alt Auto-Pairing (Nearby BLE)
A-->>B: BLE broadcast: SBM:XKQM
B-->>A: BLE broadcast: SBM:R7NP
Note over A,B: Lower code wins → determines creator/joiner
else Manual Pairing
A->>A: Display 4-char code
Note right of A: User reads code
B->>B: User enters code
end
Note over A,B: Phase 2 — ECDH Key Exchange
A->>F: Write public key (PubA) to meeting doc
B->>F: Write public key (PubB) to meeting doc
F-->>B: Read PubA
F-->>A: Read PubB
Note over A,B: Phase 3 — Shared Secret
A->>A: sharedSecret = ECDH(privA, PubB)
B->>B: sharedSecret = ECDH(privB, PubA)
Note over A,B: Both compute identical 32-byte secret
A->>A: SAS = SHA-256("sas:" + sort(PubA,PubB) + secret) → 2-digit number
B->>B: SAS = SHA-256("sas:" + sort(PubA,PubB) + secret) → 2-digit number
Note over A,B: Phase 4 — Visual Verification
A->>A: Display SAS: 42
B->>B: Display SAS: 42
Note over A,B: 👤 User compares numbers on both screens
A->>A: User confirms ✓
B->>B: User confirms ✓
Note over A,B: Phase 5 — Key Derivation
A->>A: pairingKey = SHA-256("pair:" + secret)
B->>B: pairingKey = SHA-256("pair:" + secret)
A->>A: docKey = SHA-256("doc:" + pairingKey)
A->>A: encKey = SHA-256("enc:" + pairingKey)
Note over A,B: ✅ Paired — all future signaling encrypted with AES-256-GCM
Pełna sekwencja protokołu parowania — edytowalne źródło: docs/diagrams/pairing-sequence.mmd
Krok 1: Każde urządzenie generuje parę kluczy
Po otwarciu ekranu parowania każde urządzenie generuje tymczasową parę kluczy ECDH na krzywej P-256 (secp256r1):
- Klucz prywatny — pozostaje wyłącznie na urządzeniu
- Klucz publiczny — wymieniany przez Firebase
Klucze są tworzone za pomocą kryptograficznie bezpiecznego generatora liczb losowych (Random.secure())
i są ważne tylko dla tej jednej próby parowania. Dla każdej nowej próby generowane są
nowe klucze.
Krok 2: Wymiana kluczy publicznych przez Firebase
Aby dwa urządzenia mogły się odnaleźć, Timmy używa 4-znakowego kodu jako punktu spotkania. Kod można wykryć automatycznie przez Nearby Connections (Bluetooth Low Energy) albo wpisać ręcznie. Nie ma on żadnej wartości kryptograficznej; służy jedynie temu, aby oba urządzenia trafiły do tego samego dokumentu Firebase Firestore.
Gdy oba urządzenia znają kod, każde zapisuje swój publiczny klucz ECDH we wspólnym dokumencie Firestore. Następnie każde urządzenie odczytuje z niego klucz publiczny drugiego urządzenia.
Co istotne: wysyłany jest tylko publiczny klucz. Klucz prywatny nigdy nie opuszcza urządzenia. Osoba obserwująca ruch Firebase widzi klucze publiczne, ale nie może wyliczyć wspólnego sekretu na ich podstawie. Wynika to z trudności problemu logarytmu dyskretnego na krzywych eliptycznych (ECDLP).
Krok 3: Wyliczenie wspólnego sekretu
Gdy oba urządzenia poznają klucz publiczny drugiego urządzenia, niezależnie wyliczają ten sam wspólny sekret:
sharedSecret = ECDH(myPrivateKey, remotePublicKey)
→ 32 bytes (identical on both devices)
Matematyka krzywych eliptycznych gwarantuje, że oba obliczenia dają ten sam wynik, choć każde urządzenie zna tylko własny klucz prywatny i klucz publiczny drugiego urządzenia.
Krok 4: Numer weryfikacyjny (SAS)
Ze wspólnego sekretu wyprowadzany jest krótki ciąg uwierzytelniający (SAS) — dwucyfrowy numer wyświetlany na obu urządzeniach:
hash = SHA-256("sas:" + sort(pubkeyA, pubkeyB) + sharedSecret)
number = (hash[0] × 256 + hash[1]) mod 100 → 00 to 99
Oba urządzenia wyświetlają ten sam numer — na przykład 42. Użytkownik porównuje wzrokowo numery na obu ekranach, a następnie potwierdza je na każdym urządzeniu osobno.
Dlaczego osoba atakująca nie może tego sfałszować
Atakujący typu man-in-the-middle musiałby przechwycić wymianę kluczy w Firebase. Konkretnie musiałby:
- Zastąpić prawdziwe klucze publiczne zapisane w dokumencie Firestore własnymi
- Ustanowić osobne wspólne sekrety z każdym urządzeniem
sequenceDiagram
autonumber
participant A as 📱 Device A
participant M as 🕵️ Attacker (MITM)
participant B as 📱 Device B
Note over A,B: Attacker intercepts the Firebase key exchange
A->>A: Generate key pair (privA, PubA)
B->>B: Generate key pair (privB, PubB)
M->>M: Generate TWO key pairs (privM1, PubM1) + (privM2, PubM2)
A->>M: Write PubA to Firebase
M->>M: Replace PubA with PubM1
M->>B: B reads PubM1 (thinks it is PubA)
B->>M: Write PubB to Firebase
M->>M: Replace PubB with PubM2
M->>A: A reads PubM2 (thinks it is PubB)
Note over A,B: Each device computes a DIFFERENT shared secret
A->>A: secret_A = ECDH(privA, PubM2)
M->>M: secret_A = ECDH(privM2, PubA)
M->>M: secret_B = ECDH(privM1, PubB)
B->>B: secret_B = ECDH(privB, PubM1)
Note over A,M: secret_A ≠ secret_B
A->>A: SAS_A = SHA-256("sas:" + sort(PubA,PubM2) + secret_A) → 73
B->>B: SAS_B = SHA-256("sas:" + sort(PubM1,PubB) + secret_B) → 18
rect rgb(255, 230, 230)
Note over A,B: ❌ User sees DIFFERENT numbers!
A->>A: Display: 73
B->>B: Display: 18
Note over A,B: 👤 User notices mismatch → cancels pairing
end
Note over A,B: 🛡️ Attack detected — MITM cannot force SAS match (P = 1/100)
Wykrywanie man-in-the-middle przez niezgodność SAS — edytowalne źródło: docs/diagrams/mitm-detection.mmd
W takim przypadku atakujący wylicza wspólny sekret S_A z urządzeniem A i
inny wspólny sekret S_B z urządzeniem B. Ponieważ S_A ≠ S_B,
urządzenia wyliczają różne numery weryfikacyjne.
Atakujący nie może sprawić, by numery były identyczne, ponieważ:
- Nie zna prywatnych kluczy urządzeń
- Funkcja SHA-256 jest nieodwracalna
- Prawdopodobieństwo przypadkowej zgodności wynosi tylko 1 na 100
Użytkownik widzi różne numery na ekranach i anuluje parowanie. W tym momencie atak staje się widoczny.
Krok 5: Zakończenie parowania
Dopiero po potwierdzeniu weryfikacji przez użytkownika na obu urządzeniach parowanie zostaje zakończone:
- Wyprowadzany jest 64-znakowy klucz parowania (256 bitów) ze wspólnego sekretu:
SHA-256("pair:" + sharedSecret) → pairingKey - Klucz dokumentu jest wyprowadzany jako
SHA-256("doc:" + pairingKey)i służy jako klucz dokumentu Firestore - Klucz szyfrowania jest wyprowadzany jako
SHA-256("enc:" + pairingKey)i zapewnia klucz AES-256-GCM do szyfrowanej sygnalizacji - Oba urządzenia zapisują ten sam klucz parowania i przechodzą do wyboru trybu
Od tego momentu wszystkie kolejne próby połączenia (sygnalizacja Firestore, konfiguracja WebRTC) są szyfrowane wspólnym kluczem AES-256-GCM. Klucz parowania nigdy nie jest wysyłany do backendu; jako identyfikator dokumentu używany jest wyłącznie jego hash SHA-256.
Architektura systemu
Poniższy diagram pokazuje komponenty biorące udział w parowaniu i komunikacji:
flowchart TB
BABY["📱 Baby Phone
Baby Mode"]
PARENT["📱 Parent Phone
Parent Mode"]
BABY <==>|"🔒 WebRTC Peer-to-Peer · DTLS-SRTP
Audio · Video · DataChannel"| PARENT
BABY -.-|"🔵 Bluetooth LE · Nearby
Auto-Discovery"| PARENT
subgraph FIREBASE["☁️ Firebase (Google Cloud)"]
direction LR
AUTH["🪪 Anonymous
Authentication"]
FS["📄 Firestore
Pairing + Signaling"]
CF["⚡ Cloud Functions
getTurnCredentials"]
end
BABY <-->|"🔐 AES-256-GCM encrypted
SDP · ICE · ECDH keys"| FS
FS <-->|"🔐 AES-256-GCM encrypted
SDP · ICE · ECDH keys"| PARENT
BABY -.->|Token| AUTH
PARENT -.->|Token| AUTH
STUN["📡 STUN server
stun.cloudflare.com:3478"]
TURN["🔄 TURN relay
local or Cloudflare"]
BABY & PARENT -->|Short-lived credentials| CF
CF -->|local first, Cloudflare fallback| TURN
BABY & PARENT -.->|NAT Traversal| STUN
BABY -.->|"Relay Fallback"| TURN
TURN -.->|"Relay Fallback"| PARENT
style BABY fill:#FBF6F0,stroke:#B5734A,stroke-width:2px
style PARENT fill:#FBF6F0,stroke:#B5734A,stroke-width:2px
style FIREBASE fill:#fff5f5,stroke:#E9B44C,stroke-width:2px
style AUTH fill:#E9B44C,stroke:#2B2D42
style FS fill:#E9B44C,stroke:#2B2D42
style CF fill:#E9B44C,stroke:#2B2D42
style STUN fill:#F6E3D2,stroke:#B5734A
style TURN fill:#7BC47F,stroke:#2B2D42
Przegląd architektury systemu — edytowalne źródło: docs/diagrams/pairing-architecture.mmd
Szczegóły ścieżek komunikacji:
- WebRTC peer-to-peer (gruba linia): dźwięk, obraz i DataChannel płyną bezpośrednio między urządzeniami — szyfrowane przez DTLS-SRTP. Żaden serwer nie widzi tych danych.
- Firebase Firestore (ciągła linia): dane parowania (klucze ECDH) i sygnalizacja (SDP/ICE) przechodzą przez Firestore — są szyfrowane end-to-end za pomocą AES-256-GCM. Firebase nie może odszyfrować tych danych.
- Serwer STUN: oba urządzenia wykrywają swój publiczny adres IP, aby można było ustanowić bezpośrednie połączenie peer-to-peer.
- Przekaźnik TURN: jeśli bezpośrednie połączenie nie jest możliwe (np. przez internet mobilny), wybrany lokalny serwer TURN lub Cloudflare TURN przekazuje zaszyfrowane multimedia. Krótkotrwałe dane uwierzytelniające (24 h) są pobierane przez Firebase Cloud Functions.
- Bluetooth LE (linia kropkowana): Nearby Connections automatycznie wykrywa urządzenia w pobliżu — przesyłany jest tylko kod spotkania, bez materiału kluczowego.
Opcja awaryjna: ręczne wpisanie kodu
Jeśli Bluetooth jest niedostępny (np. na starszych urządzeniach), 4-znakowy kod można również wpisać ręcznie. Ręczne wpisanie wykorzystuje tę samą wymianę kluczy ECDH i tę samą weryfikację SAS co parowanie automatyczne. Jedyna różnica polega na tym, że użytkownik odczytuje i wpisuje kod zamiast wykrywać go przez BLE.
Ponieważ wymiana kluczy ECDH w obu przypadkach odbywa się przez Firebase, poziom bezpieczeństwa jest identyczny. 4-znakowy kod jest tylko punktem spotkania; właściwe szyfrowanie opiera się na 256-bitowym kluczu wyprowadzonym z ECDH.
Podsumowanie
| Mechanizm bezpieczeństwa | Chroni przed |
|---|---|
| Wymiana kluczy ECDH (P-256) | Podsłuchem ruchu podczas wymiany kluczy |
| Tymczasowe pary kluczy | Utajnienie z wyprzedzeniem — wcześniejsze parowania pozostają bezpieczne |
| Wizualny numer weryfikacyjny (SAS) | Atakiem man-in-the-middle (MITM) podczas wymiany kluczy |
| Hash SHA-256 jako klucz dokumentu | Wydobyciem kodu z Firestore |
| Szyfrowanie AES-256-GCM | Podsłuchem danych sygnalizacyjnych |
| Potwierdzenie po obu stronach | Jednostronnym parowaniem bez wiedzy użytkownika |
| DTLS-SRTP (WebRTC) | Podsłuchem dźwięku/obrazu |
Te warstwy współpracują ze sobą: ECDH chroni wymianę kluczy, numer weryfikacyjny chroni przed MITM, AES-256-GCM chroni sygnalizację, a WebRTC chroni multimedia. Atakujący musiałby złamać ten łańcuch w kilku miejscach, nie wzbudzając podejrzeń urządzeń ani rodziców.