Voordat Baby Monitor Timmy audio en video verstuurt, moeten twee apparaten elkaar vinden en elkaar vertrouwen. Deze koppelings stap is het belangrijkste moment in het hele proces. Hier leg ik uit hoe Timmy koppelt, welke cryptografie daarachter zit en waarom een aanvaller in de buurt de verbinding niet ongemerkt kan overnemen.
Het probleem: hoe weet mijn apparaat met wie het praat?
Wanneer twee apparaten voor het eerst verbinding maken, is de centrale vraag: praat apparaat A echt met apparaat B, of zit er iemand tussenin? In de cryptografie heet dat een man-in-the-middleaanval (MITM).
Timmy lost dit op met een Elliptic Curve Diffie-Hellman-sleuteluitwisseling (ECDH) via Firebase, gecombineerd met visuele verificatie door de gebruiker.
Het volgende diagram toont in één oogopslag het volledige koppelingsproces:
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
Volledige volgorde van het koppelingsprotocol — bewerkbare bron: docs/diagrams/pairing-sequence.mmd
Stap 1: Elk apparaat genereert een sleutelpaar
Bij het openen van het koppelscherm genereert elk apparaat een tijdelijk ECDH-sleutelpaar op de P-256-curve (secp256r1):
- Een privésleutel — blijft uitsluitend op het apparaat
- Een openbare sleutel — wordt uitgewisseld via Firebase
De sleutels worden gemaakt met een cryptografisch veilige generator voor willekeurige getallen (Random.secure())
en zijn alleen geldig voor deze ene koppelingspoging. Voor elke nieuwe poging worden
nieuwe sleutels gegenereerd.
Stap 2: Openbare sleutels uitwisselen via Firebase
Om twee apparaten elkaar te laten vinden, gebruikt Timmy een code van 4 tekens als ontmoetingspunt. Deze code kan automatisch worden gevonden via Nearby Connections (Bluetooth Low Energy) of handmatig worden ingevoerd. De code heeft geen cryptografische waarde; hij zorgt er alleen voor dat beide apparaten hetzelfde Firebase Firestore-document vinden.
Zodra beide apparaten de code kennen, schrijft elk zijn openbare ECDH-sleutel naar een gedeeld Firestore-document. Daarna leest elk apparaat de openbare sleutel van het andere apparaat uit dat document.
Belangrijk: alleen de openbare sleutel wordt verzonden. De privésleutel verlaat het apparaat nooit. Wie het Firebase-verkeer bekijkt, ziet openbare sleutels, maar kan het gedeelde geheim niet berekenen op basis daarvan. Dit berust op de moeilijkheid van het Elliptic Curve Discrete Logarithm Problem (ECDLP).
Stap 3: Het gedeelde geheim berekenen
Zodra beide apparaten elkaars openbare sleutel hebben gevonden, berekenen ze onafhankelijk van elkaar hetzelfde gedeelde geheim:
sharedSecret = ECDH(myPrivateKey, remotePublicKey)
→ 32 bytes (identical on both devices)
De wiskunde van elliptische krommen garandeert dat beide berekeningen hetzelfde resultaat opleveren, ook al kent elk apparaat alleen zijn eigen privésleutel en de openbare sleutel van het andere apparaat.
Stap 4: Het verificatienummer (SAS)
Uit het gedeelde geheim wordt een Short Authentication String (SAS) afgeleid — een tweecijferig nummer dat op beide apparaten wordt weergegeven:
hash = SHA-256("sas:" + sort(pubkeyA, pubkeyB) + sharedSecret)
number = (hash[0] × 256 + hash[1]) mod 100 → 00 to 99
Beide apparaten tonen hetzelfde nummer — bijvoorbeeld 42. De gebruiker vergelijkt visueel of de nummers op beide schermen overeenkomen en bevestigt daarna op elk apparaat afzonderlijk.
Waarom een aanvaller dit niet kan vervalsen
Een man-in-the-middle zou de sleuteluitwisseling in Firebase moeten onderscheppen. Concreet zou diegene:
- De echte openbare sleutels in het Firestore-document moeten vervangen door eigen sleutels
- Met elk apparaat een afzonderlijk gedeeld geheim moeten opzetten
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)
Detectie van man-in-the-middle via een afwijkend SAS — bewerkbare bron: docs/diagrams/mitm-detection.mmd
In dit geval berekent de aanvaller een gedeeld geheim S_A met apparaat A en een
ander gedeeld geheim S_B met apparaat B. Omdat S_A ≠ S_B,
berekenen de apparaten verschillende verificatienummers.
De aanvaller kan de nummers niet gelijk krijgen, omdat:
- Die de privésleutels van de apparaten niet kent
- SHA-256 niet omkeerbaar is
- De kans op een willekeurige overeenkomst slechts 1 op 100
De gebruiker ziet verschillende nummers op de schermen en annuleert het koppelen. Op dat moment is de aanval zichtbaar geworden.
Stap 5: Het koppelen voltooien
Pas nadat de gebruiker de verificatie op beide apparaten heeft bevestigd, wordt het koppelen voltooid:
- Een koppelingssleutel van 64 tekens (256 bits) wordt afgeleid uit het gedeelde geheim:
SHA-256("pair:" + sharedSecret) → pairingKey - De documentsleutel wordt afgeleid als
SHA-256("doc:" + pairingKey)en dient als sleutel voor het Firestore-document - De versleutelingssleutel wordt afgeleid als
SHA-256("enc:" + pairingKey)en levert de AES-256-GCM-sleutel voor versleutelde signalering - Beide apparaten slaan dezelfde koppelingssleutel op en gaan naar de moduskeuze
Vanaf dit moment worden alle verdere verbindingspogingen (Firestore-signalering, WebRTC-instelling) versleuteld met de gedeelde AES-256-GCM-sleutel. De koppelingssleutel wordt nooit naar de backend verzonden; alleen de SHA-256-hash ervan wordt gebruikt als document-ID.
Systeemarchitectuur
Het volgende diagram toont de onderdelen die betrokken zijn bij koppelen en communicatie:
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
Overzicht van de systeemarchitectuur — bewerkbare bron: docs/diagrams/pairing-architecture.mmd
Communicatiepaden in detail:
- WebRTC peer-to-peer (dikke lijn): Audio, video en DataChannel gaan rechtstreeks tussen apparaten heen en weer — versleuteld met DTLS-SRTP. Geen server ziet deze gegevens.
- Firebase Firestore (doorgetrokken lijn): Koppelingsgegevens (ECDH-sleutels) en signalering (SDP/ICE) gaan via Firestore — end-to-end versleuteld met AES-256-GCM. Firebase kan de gegevens niet ontsleutelen.
- STUN-server: Beide apparaten ontdekken hun openbare IP-adres, zodat een rechtstreekse peer-to-peerverbinding kan worden opgezet.
- TURN-relay: Als een rechtstreekse verbinding niet mogelijk is (bijvoorbeeld via mobiele data), stuurt de gekozen lokale of Cloudflare TURN-server de versleutelde media door. Kortdurende inloggegevens (24 uur) worden opgehaald via Firebase Cloud Functions.
- Bluetooth LE (stippellijn): Nearby Connections vindt apparaten in de buurt automatisch — alleen de ontmoetingscode wordt verstuurd, geen sleutelmateriaal.
Terugvaloptie: code handmatig invoeren
Als Bluetooth niet beschikbaar is (bijvoorbeeld op oudere apparaten), kan de code van 4 tekens ook handmatig worden ingevoerd. Bij handmatige invoer worden dezelfde ECDH-sleuteluitwisseling en dezelfde SAS-verificatie gebruikt als bij automatisch koppelen. Het enige verschil is dat de gebruiker de code leest en invoert in plaats van dat deze via BLE wordt gevonden.
Omdat de ECDH-sleuteluitwisseling in beide gevallen via Firebase verloopt, is de beveiliging identiek. De code van 4 tekens is alleen een ontmoetingspunt; de echte versleuteling is gebaseerd op de uit ECDH afgeleide 256-bits sleutel.
Samenvatting
| Beveiligingsmechanisme | Beschermt tegen |
|---|---|
| ECDH-sleuteluitwisseling (P-256) | Afluisteren van sleuteluitwisselingsverkeer |
| Tijdelijke sleutelparen | Forward secrecy — eerdere koppelingen blijven veilig |
| Visueel verificatienummer (SAS) | Man-in-the-middle (MITM) tijdens sleuteluitwisseling |
| SHA-256-hash als documentsleutel | De code uit Firestore achterhalen |
| AES-256-GCM-versleuteling | Afluisteren van signaleringsgegevens |
| Bevestiging aan beide kanten | Eenzijdig koppelen zonder medeweten van de gebruiker |
| DTLS-SRTP (WebRTC) | Afluisteren van audio/video |
Deze lagen vullen elkaar aan: ECDH beschermt de sleuteluitwisseling, het verificatienummer beschermt tegen MITM, AES-256-GCM beschermt de signalering en WebRTC beschermt de media. Een aanvaller zou deze keten op meerdere punten moeten doorbreken zonder dat de apparaten of ouders het merken.