[{"data":1,"prerenderedAt":1027},["ShallowReactive",2],{"i-ph:moon-bold":3,"i-ph:globe-simple":8,"i-ph:caret-down":10,"i-ph:list":12,"i-ph:heart-fill":14,"i-ph:discord-logo-bold":16,"i-ph:github-logo-bold":18,"i-ph:cookie-bold":20,"blog-article-en-pairing-flow":22,"i-ph:arrow-left":1025},{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":7},0,256,false,"\u003Cpath fill=\"currentColor\" d=\"M236.37 139.4a12 12 0 0 0-12-3A84.07 84.07 0 0 1 119.6 31.59a12 12 0 0 0-15-15a108.86 108.86 0 0 0-54.91 38.48A108 108 0 0 0 136 228a107.1 107.1 0 0 0 64.93-21.69a108.86 108.86 0 0 0 38.44-54.94a12 12 0 0 0-3-11.97m-49.88 47.74A84 84 0 0 1 68.86 69.51a84.9 84.9 0 0 1 23.41-21.22Q92 52.13 92 56a108.12 108.12 0 0 0 108 108q3.87 0 7.71-.27a84.8 84.8 0 0 1-21.22 23.41\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":9},"\u003Cpath fill=\"currentColor\" d=\"M128 24a104 104 0 1 0 104 104A104.12 104.12 0 0 0 128 24m87.62 96h-39.83c-1.79-36.51-15.85-62.33-27.38-77.6a88.19 88.19 0 0 1 67.22 77.6ZM96.23 136h63.54c-2.31 41.61-22.23 67.11-31.77 77c-9.55-9.9-29.46-35.4-31.77-77m0-16c2.31-41.61 22.23-67.11 31.77-77c9.55 9.93 29.46 35.43 31.77 77Zm11.36-77.6C96.06 57.67 82 83.49 80.21 120H40.37a88.19 88.19 0 0 1 67.22-77.6M40.37 136h39.84c1.82 36.51 15.85 62.33 27.38 77.6A88.19 88.19 0 0 1 40.37 136m108 77.6c11.53-15.27 25.56-41.09 27.38-77.6h39.84a88.19 88.19 0 0 1-67.18 77.6Z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":11},"\u003Cpath fill=\"currentColor\" d=\"m213.66 101.66l-80 80a8 8 0 0 1-11.32 0l-80-80a8 8 0 0 1 11.32-11.32L128 164.69l74.34-74.35a8 8 0 0 1 11.32 11.32\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":13},"\u003Cpath fill=\"currentColor\" d=\"M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8M40 72h176a8 8 0 0 0 0-16H40a8 8 0 0 0 0 16m176 112H40a8 8 0 0 0 0 16h176a8 8 0 0 0 0-16\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":15},"\u003Cpath fill=\"currentColor\" d=\"M240 102c0 70-103.79 126.66-108.21 129a8 8 0 0 1-7.58 0C119.79 228.66 16 172 16 102a62.07 62.07 0 0 1 62-62c20.65 0 38.73 8.88 50 23.89C139.27 48.88 157.35 40 178 40a62.07 62.07 0 0 1 62 62\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":17},"\u003Cpath fill=\"currentColor\" d=\"M108 136a16 16 0 1 1-16-16a16 16 0 0 1 16 16m56-16a16 16 0 1 0 16 16a16 16 0 0 0-16-16m76.07 76.56l-67 29.71A20.15 20.15 0 0 1 146 214.9l-8.54-23.13c-3.13.14-6.27.24-9.45.24s-6.32-.1-9.45-.24L110 214.9a20.19 20.19 0 0 1-27.08 11.37l-67-29.71a19.93 19.93 0 0 1-11.3-23.15L34.15 57a20 20 0 0 1 16.22-14.81l36.06-5.93a20.26 20.26 0 0 1 22.79 14.84l4.41 17.41c4.74-.33 9.52-.51 14.37-.51s9.63.18 14.37.51l4.41-17.41a20.25 20.25 0 0 1 22.79-14.84l36.06 5.93A20 20 0 0 1 221.85 57l29.53 116.38a19.93 19.93 0 0 1-11.31 23.18M227.28 176L199.23 65.46l-30.07-4.94l-2.84 11.17c2.9.58 5.78 1.2 8.61 1.92a12 12 0 1 1-5.86 23.27A168.4 168.4 0 0 0 128 92a168.4 168.4 0 0 0-41.07 4.88a12 12 0 0 1-5.86-23.27c2.83-.72 5.71-1.34 8.61-1.92l-2.83-11.17l-30.08 4.94L28.72 176l60.22 26.7l5-13.57c-4.37-.76-8.67-1.65-12.88-2.71a12 12 0 0 1 5.86-23.28A168.4 168.4 0 0 0 128 168a168.4 168.4 0 0 0 41.07-4.88a12 12 0 0 1 5.86 23.28c-4.21 1.06-8.51 1.95-12.88 2.71l5 13.57Z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":19},"\u003Cpath fill=\"currentColor\" d=\"M212.62 75.17A63.7 63.7 0 0 0 206.39 26A12 12 0 0 0 196 20a63.71 63.71 0 0 0-50 24h-20a63.71 63.71 0 0 0-50-24a12 12 0 0 0-10.39 6a63.7 63.7 0 0 0-6.23 49.17A61.5 61.5 0 0 0 52 104v8a60.1 60.1 0 0 0 45.76 58.28A43.66 43.66 0 0 0 92 192v4H76a20 20 0 0 1-20-20a44.05 44.05 0 0 0-44-44a12 12 0 0 0 0 24a20 20 0 0 1 20 20a44.05 44.05 0 0 0 44 44h16v12a12 12 0 0 0 24 0v-40a20 20 0 0 1 40 0v40a12 12 0 0 0 24 0v-40a43.66 43.66 0 0 0-5.76-21.72A60.1 60.1 0 0 0 220 112v-8a61.5 61.5 0 0 0-7.38-28.83M196 112a36 36 0 0 1-36 36h-48a36 36 0 0 1-36-36v-8a37.87 37.87 0 0 1 6.13-20.12a11.65 11.65 0 0 0 1.58-11.49a39.9 39.9 0 0 1-.4-27.72a39.87 39.87 0 0 1 26.41 17.8a12 12 0 0 0 10.1 5.53h32.35a12 12 0 0 0 10.11-5.53a39.84 39.84 0 0 1 26.41-17.8a39.9 39.9 0 0 1-.4 27.72a12 12 0 0 0 1.61 11.53A37.85 37.85 0 0 1 196 104Z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":21},"\u003Cpath fill=\"currentColor\" d=\"M167.31 160.69a16 16 0 1 1-22.62 0a16 16 0 0 1 22.62 0m-86.62-8a16 16 0 1 0 22.62 0a16 16 0 0 0-22.62 0m14.62-33.38a16 16 0 1 0-22.62 0a16 16 0 0 0 22.62 0m48-6.62a16 16 0 1 0 0 22.62a16 16 0 0 0 0-22.62M236 128A108 108 0 1 1 128 20a12 12 0 0 1 12 12a36 36 0 0 0 36 36a12 12 0 0 1 12 12a36 36 0 0 0 36 36a12 12 0 0 1 12 12m-24.67 10.65A60.17 60.17 0 0 1 165 91a60.17 60.17 0 0 1-47.66-46.32a84 84 0 1 0 94 94Z\"\u002F>",{"id":23,"title":24,"body":25,"category":1012,"date":1013,"description":1014,"extension":1015,"meta":1016,"navigation":1017,"path":1018,"readingTime":1019,"seo":1020,"seoDescription":1021,"seoTitle":1022,"stem":1023,"__hash__":1024},"blog_en\u002Fblog\u002Fpairing-flow.md","Pairing Flow",{"type":26,"value":27,"toc":972},"minimark",[28,33,110,113,122,142,161,168,171,174,241,248,263,266,269,280,286,291,405,408,414,420,424,437,452,455,459,478,481,484,490,494,516,520,531,534,537,543,547,590,593,596,603,609,617,634,637,640,646,661,664,675,681,689,702,705,708,714,720,726,756,759,916,920,944,947,953,957],[29,30,32],"h2",{"id":31},"table-of-contents","Table of Contents",[34,35,36,44,50,56,62,68,74,80,86,92,98,104],"ul",{},[37,38,39],"li",{},[40,41,43],"a",{"href":42},"#why-pairing-exists","Why Pairing Exists",[37,45,46],{},[40,47,49],{"href":48},"#trust-model--cryptography","Trust Model & Cryptography",[37,51,52],{},[40,53,55],{"href":54},"#component-map","Component Map",[37,57,58],{},[40,59,61],{"href":60},"#discovery-phase","Discovery Phase",[37,63,64],{},[40,65,67],{"href":66},"#pairing-sequence-happy-path","Pairing Sequence (Happy Path)",[37,69,70],{},[40,71,73],{"href":72},"#key-exchange-details","Key Exchange Details",[37,75,76],{},[40,77,79],{"href":78},"#responder-acceptdecline-flow","Responder Accept\u002FDecline Flow",[37,81,82],{},[40,83,85],{"href":84},"#cancel-flow","Cancel Flow",[37,87,88],{},[40,89,91],{"href":90},"#dual-channel-delivery-lan--ble","Dual-Channel Delivery (LAN + BLE)",[37,93,94],{},[40,95,97],{"href":96},"#session--peer-storage","Session & Peer Storage",[37,99,100],{},[40,101,103],{"href":102},"#security-properties","Security Properties",[37,105,106],{},[40,107,109],{"href":108},"#state-machine-recap","State Machine Recap",[29,111,43],{"id":112},"why-pairing-exists",[114,115,116,117,121],"p",{},"PlainApp has ",[118,119,120],"strong",{},"no central account server",". Devices must therefore answer two\nquestions before they can talk:",[123,124,125,136],"ol",{},[37,126,127,130,131,135],{},[118,128,129],{},"\"Who are you?\""," — every device generates a stable ",[132,133,134],"code",{},"clientId"," on first\nlaunch (a 13-character id derived from its Ed25519 key material). This is\nthe only identifier used for routing, presence, and channel membership.",[37,137,138,141],{},[118,139,140],{},"\"Can I trust you?\""," — without a server to vouch for identity, the only\nway to be sure a peer is who they claim to be is for a human to confirm\nthe pairing on both devices and for the protocol to verify cryptographic\nsignatures.",[114,143,144,145,148,149,152,153,156,157,160],{},"Pairing produces a single artifact: a ",[132,146,147],{},"DPeer"," row in the database with\n",[132,150,151],{},"status=\"paired\"",", a ChaCha20 ",[132,154,155],{},"key"," (the shared transport secret), and the\npeer's Ed25519 ",[132,158,159],{},"public_key"," (for verifying future message signatures). Every\nlater protocol in the chat subsystem assumes these two fields exist.",[114,162,163],{},[164,165],"img",{"alt":166,"src":167},"Diagram 1","\u002Fblog\u002Fpairing-flow\u002Fdiagram-01.svg",[29,169,49],{"id":170},"trust-model-cryptography",[114,172,173],{},"Pairing uses two independent cryptographic primitives:",[175,176,177,193],"table",{},[178,179,180],"thead",{},[181,182,183,187,190],"tr",{},[184,185,186],"th",{},"Primitive",[184,188,189],{},"Purpose",[184,191,192],{},"Lifecycle",[194,195,196,223],"tbody",{},[181,197,198,205,208],{},[199,200,201,204],"td",{},[118,202,203],{},"Ed25519"," (signature)",[199,206,207],{},"Authenticate the pairing request and response. Verifies \"this really came from the device claiming to send it\" and binds the timestamp to prevent replay.",[199,209,210,211,214,215,218,219,222],{},"The signing key is the device's ",[118,212,213],{},"long-term identity key",". Its public half is stored as ",[132,216,217],{},"DPeer.public_key"," and later used by ",[132,220,221],{},"PeerChatParser.decrypt"," to verify every chat message signature.",[181,224,225,231,234],{},[199,226,227,230],{},[118,228,229],{},"X25519-style ECDH"," (key agreement)",[199,232,233],{},"Produce a shared secret that becomes the ChaCha20 transport key. The two devices compute the same secret without ever transmitting it.",[199,235,236,237,240],{},"Ephemeral key pair generated per pairing session, discarded immediately after the shared key is computed. The resulting 32-byte secret is stored as ",[132,238,239],{},"DPeer.key"," and reused for the lifetime of the pairing.",[114,242,243,244,247],{},"There is ",[118,245,246],{},"no PIN, no QR code, no out-of-band code",". Trust is established by:",[123,249,250,257,260],{},[37,251,252,253,256],{},"A human tapping ",[118,254,255],{},"Accept"," on the responder device (the user is asserting\n\"yes, this is the device I want to pair with\").",[37,258,259],{},"Both sides verifying the other's Ed25519 signature on the request\u002Fresponse\n(proving the responder is talking to the same device that started the\nsession and vice versa).",[37,261,262],{},"A ±5 min timestamp window on both messages (preventing replay of an old\ncaptured handshake).",[114,264,265],{},"The asymmetry matters: a single human confirmation would be vulnerable to a\nman-in-the-middle (the attacker could pair with both sides separately). The\nEd25519 signature on the ECDH public key prevents this — the responder\nverifies the request was signed by the same Ed25519 key that initiated the\nsession, and vice versa, so an MITM cannot transparently substitute its own\nECDH key without also controlling the long-term signing key.",[29,267,55],{"id":268},"component-map",[114,270,271,272,275,276,279],{},"All pairing code lives in the ",[132,273,274],{},"discover\u002F"," package (not ",[132,277,278],{},"chat\u002Fpeer\u002Fpair\u002F","):",[114,281,282],{},[164,283],{"alt":284,"src":285},"Diagram 2","\u002Fblog\u002Fpairing-flow\u002Fdiagram-02.svg",[287,288,290],"h3",{"id":289},"file-locations","File locations",[175,292,293,307],{},[178,294,295],{},[181,296,297,300],{},[184,298,299],{},"Component",[184,301,302,303,306],{},"Path (under ",[132,304,305],{},"shared\u002Fsrc\u002FcommonMain\u002Fkotlin\u002Fcom\u002Fismartcoding\u002Fplain\u002F",")",[194,308,309,321,333,345,357,369,381,393],{},[181,310,311,316],{},[199,312,313],{},[132,314,315],{},"LANDiscoverManager",[199,317,318],{},[132,319,320],{},"discover\u002FLANDiscoverManager.kt",[181,322,323,328],{},[199,324,325],{},[132,326,327],{},"PairingCore",[199,329,330],{},[132,331,332],{},"discover\u002FPairingCore.kt",[181,334,335,340],{},[199,336,337],{},[132,338,339],{},"PairingInitiator",[199,341,342],{},[132,343,344],{},"discover\u002FPairingInitiator.kt",[181,346,347,352],{},[199,348,349],{},[132,350,351],{},"PairingResponder",[199,353,354],{},[132,355,356],{},"discover\u002FPairingResponder.kt",[181,358,359,364],{},[199,360,361],{},[132,362,363],{},"PairingSecurity",[199,365,366],{},[132,367,368],{},"discover\u002FPairingSecurity.kt",[181,370,371,376],{},[199,372,373],{},[132,374,375],{},"PairingSessionStore",[199,377,378],{},[132,379,380],{},"discover\u002FPairingSessionStore.kt",[181,382,383,388],{},[199,384,385],{},[132,386,387],{},"PairingPeerStore",[199,389,390],{},[132,391,392],{},"discover\u002FPairingPeerStore.kt",[181,394,395,400],{},[199,396,397],{},[132,398,399],{},"PairingMessenger",[199,401,402],{},[132,403,404],{},"discover\u002FPairingMessenger.kt",[29,406,61],{"id":407},"discovery-phase",[114,409,410,411,413],{},"Before pairing can happen, devices must find each other.\n",[132,412,315],{}," runs continuously once the app starts:",[114,415,416],{},[164,417],{"alt":418,"src":419},"Diagram 3","\u002Fblog\u002Fpairing-flow\u002Fdiagram-03.svg",[287,421,423],{"id":422},"why-directed-discovery-is-encrypted","Why directed discovery is encrypted",[114,425,426,427,430,431,433,434,436],{},"The broadcast DISCOVER reveals nothing sensitive (just ",[132,428,429],{},"fromId=clientId","), so\nit's fine for any device on the LAN to see it. The directed variant, however,\nis used when one device already knows another's ",[132,432,134],{}," (e.g. it's paired\nbut the peer's IP has changed) and wants to wake it up. Encrypting the\ntarget ",[132,435,134],{}," with the peer's shared key means:",[34,438,439,446],{},[37,440,441,442,445],{},"The right peer can decrypt the ",[132,443,444],{},"toId",", recognize itself, and reply.",[37,447,448,449,451],{},"Every other device on the LAN sees only ciphertext — they cannot enumerate\nwhich ",[132,450,134],{},"s the sender is trying to reach.",[114,453,454],{},"This is a small but real privacy property: passive LAN observers cannot\nbuild a graph of who is paired with whom.",[287,456,458],{"id":457},"aware-flags-in-the-reply","Aware flags in the reply",[114,460,461,462,465,466,469,470,473,474,477],{},"The DISCOVER_REPLY carries ",[132,463,464],{},"awareSupported"," and ",[132,467,468],{},"awareRunning",". These are\n",[118,471,472],{},"not persisted"," to the database — they're stored in-memory in ",[132,475,476],{},"PeerCacher","\nand refreshed on every reply (and also from BLE scan-response serviceData).\nThe transport layer consults them to decide whether to attempt a Wi-Fi Aware\nlink or skip straight to BLE.",[29,479,67],{"id":480},"pairing-sequence-happy-path",[114,482,483],{},"The end-to-end flow when both devices are on the same LAN and the user\naccepts the pairing:",[114,485,486],{},[164,487],{"alt":488,"src":489},"Diagram 4","\u002Fblog\u002Fpairing-flow\u002Fdiagram-04.svg",[287,491,493],{"id":492},"why-both-sides-store-the-peer-independently","Why both sides store the peer independently",[114,495,496,497,500,501,504,505,509,510,512,513,515],{},"Notice that ",[118,498,499],{},"both"," the initiator (step 9) and the responder (step 7) call\n",[132,502,503],{},"PairingPeerStore.save(...)"," for the ",[506,507,508],"em",{},"other"," device. This is intentional: each\ndevice ends up with a ",[132,511,147],{}," row keyed by the other's ",[132,514,134],{},", containing\nits own copy of the shared ChaCha20 key and the other's Ed25519 public key.\nThere is no central registry — the pairing is symmetric and self-contained.",[287,517,519],{"id":518},"why-the-responder-computes-the-key-first","Why the responder computes the key first",[114,521,522,523,526,527,530],{},"The responder's ",[132,524,525],{},"acceptPairingRequest"," computes the shared key immediately\nupon acceptance and persists it. This means the responder can start\nreceiving encrypted traffic ",[118,528,529],{},"before"," the response arrives back at the\ninitiator. If the response is lost in transit, the responder is still\npaired — only the initiator needs to retry.",[29,532,73],{"id":533},"key-exchange-details",[114,535,536],{},"The cryptographic core of pairing is a standard X25519-style ECDH\nkey agreement, but with an Ed25519 signature layered on top to authenticate\nit.",[114,538,539],{},[164,540],{"alt":541,"src":542},"Diagram 5","\u002Fblog\u002Fpairing-flow\u002Fdiagram-05.svg",[287,544,546],{"id":545},"what-the-signature-actually-protects","What the signature actually protects",[114,548,549,550,553,554,557,558,557,561,557,564,567,568,557,571,557,574,577,578,581,582,586,587,589],{},"The signed payload (",[132,551,552],{},"toSignatureData()",") is a canonical concatenation of the\nstable request fields: ",[132,555,556],{},"fromId",", ",[132,559,560],{},"fromName",[132,562,563],{},"port",[132,565,566],{},"deviceType",",\n",[132,569,570],{},"ecdhPublicKey",[132,572,573],{},"signaturePublicKey",[132,575,576],{},"timestamp",", and ",[132,579,580],{},"ips",". By signing\nthe ",[118,583,584],{},[132,585,570],{}," together with the long-term ",[132,588,573],{},",\nthe protocol binds the ephemeral key to the device's identity. An attacker\ncannot substitute their own ECDH public key in transit without invalidating\nthe signature — and they cannot forge the signature without controlling the\nlong-term Ed25519 key.",[114,591,592],{},"This is what defeats a man-in-the-middle: even if the attacker relays every\npacket between the two devices, they cannot read the encrypted traffic\n(because they don't have either side's ECDH private key) and they cannot\nsubstitute their own ECDH keys (because the signatures would break).",[29,594,79],{"id":595},"responder-acceptdecline-flow",[114,597,598,599,602],{},"The responder side exposes a UI dialog when a ",[132,600,601],{},"PAIR_REQUEST"," arrives. The\nuser can either accept or decline.",[114,604,605],{},[164,606],{"alt":607,"src":608},"Diagram 6","\u002Fblog\u002Fpairing-flow\u002Fdiagram-06.svg",[287,610,612,613,616],{"id":611},"why-the-responder-fires-pairingsuccessevent-immediately-on-accept","Why the responder fires ",[132,614,615],{},"PairingSuccessEvent"," immediately on accept",[114,618,522,619,621,622,624,625,627,628,630,631,633],{},[132,620,525],{}," calls ",[132,623,503],{},"\nand fires ",[132,626,615],{}," ",[118,629,529],{}," sending the response. This is\ndeliberate: if the response never reaches the initiator (network glitch),\nthe responder is still paired — the next time the initiator tries to pair,\nthe responder's already-existing ",[132,632,147],{}," row will be picked up by the\npresence system. The initiator simply retries; the responder does not need\nto re-confirm.",[29,635,85],{"id":636},"cancel-flow",[114,638,639],{},"Either side can cancel an in-flight pairing.",[114,641,642],{},[164,643],{"alt":644,"src":645},"Diagram 7","\u002Fblog\u002Fpairing-flow\u002Fdiagram-07.svg",[114,647,648,649,652,653,656,657,660],{},"Note that ",[132,650,651],{},"DPairingCancel"," is sent over ",[118,654,655],{},"LAN unicast only"," (the initiator\nalready has the responder's IP from the discovery phase), whereas the\ndecline response is sent over ",[118,658,659],{},"both LAN and BLE"," because the responder\ncannot be sure which transport the initiator is reachable on.",[29,662,91],{"id":663},"dual-channel-delivery-lan-ble",[114,665,666,667,670,671,674],{},"When the responder sends the ",[132,668,669],{},"DPairingResponse",", it does so over ",[118,672,673],{},"both LAN\nand BLE simultaneously",". The initiator accepts the first copy and silently\ndiscards the duplicate.",[114,676,677],{},[164,678],{"alt":679,"src":680},"Diagram 8","\u002Fblog\u002Fpairing-flow\u002Fdiagram-08.svg",[287,682,684,685,688],{"id":683},"why-blepairingsessionstore-exists","Why ",[132,686,687],{},"BlePairingSessionStore"," exists",[114,690,691,692,694,695,697,698,701],{},"When a ",[132,693,601],{}," arrives over BLE, the responder doesn't have a LAN IP\nfor the initiator — only its BLE MAC address. ",[132,696,687],{}," maps\n",[132,699,700],{},"peerId → MAC"," so the response can be routed back over BLE if needed. This\nis a small, in-memory, ephemeral map that is only populated for BLE-routed\nrequests and cleared once the response is sent.",[29,703,97],{"id":704},"session-peer-storage",[114,706,707],{},"Two stores participate in pairing, with very different lifetimes:",[114,709,710],{},[164,711],{"alt":712,"src":713},"Diagram 9","\u002Fblog\u002Fpairing-flow\u002Fdiagram-09.svg",[287,715,684,717,719],{"id":716},"why-clientid-is-the-only-persisted-identifier",[132,718,134],{}," is the only persisted identifier",[114,721,722,723,725],{},"Android randomizes the BLE MAC address on every connection, so storing it\nwould be useless. The ",[132,724,134],{}," is derived from the device's long-term\nEd25519 key material, so it is:",[34,727,728,734,743],{},[37,729,730,733],{},[118,731,732],{},"Stable"," across app reinstalls (the key is in the platform keystore).",[37,735,736,739,740,742],{},[118,737,738],{},"Self-authenticating"," — anyone claiming a ",[132,741,134],{}," must prove they hold\nthe corresponding Ed25519 private key (verified on every signed message).",[37,744,745,748,749,752,753,755],{},[118,746,747],{},"Privacy-preserving"," — only an 8-byte SHA-256 prefix (",[132,750,751],{},"shortId",") is ever\nbroadcast over BLE for discovery; the full ",[132,754,134],{}," is only revealed to\ndevices you actually pair with.",[29,757,103],{"id":758},"security-properties",[175,760,761,771],{},[178,762,763],{},[181,764,765,768],{},[184,766,767],{},"Property",[184,769,770],{},"How it's achieved",[194,772,773,783,811,821,844,858,871,888,904],{},[181,774,775,780],{},[199,776,777],{},[118,778,779],{},"Confidentiality",[199,781,782],{},"All transport is ChaCha20 encrypted with the ECDH-derived shared key. The key never leaves the two devices after pairing.",[181,784,785,790],{},[199,786,787],{},[118,788,789],{},"Authentication",[199,791,792,793,796,797,800,801,800,804,807,808,810],{},"Every signed message (pairing request\u002Fresponse, chat ",[132,794,795],{},"createChatItem",", channel ",[132,798,799],{},"invite","\u002F",[132,802,803],{},"update",[132,805,806],{},"kick",") is Ed25519-verified against the sender's stored ",[132,809,159],{},".",[181,812,813,818],{},[199,814,815],{},[118,816,817],{},"Integrity",[199,819,820],{},"Ed25519 signatures cover the full request body; any tampering invalidates the signature.",[181,822,823,828],{},[199,824,825],{},[118,826,827],{},"Replay resistance",[199,829,830,833,834,465,837,839,840,843],{},[132,831,832],{},"±5 min timestamp window"," (enforced by ",[132,835,836],{},"PeerChatParser",[132,838,363],{},"). ",[132,841,842],{},"ChatMessageReceiver.seenSignatures"," dedups within the window.",[181,845,846,851],{},[199,847,848],{},[118,849,850],{},"Man-in-the-middle resistance",[199,852,853,854,857],{},"The ephemeral ECDH public key is ",[118,855,856],{},"signed together with"," the long-term Ed25519 public key. An MITM cannot substitute its own ECDH key without breaking the signature.",[181,859,860,865],{},[199,861,862],{},[118,863,864],{},"Forward secrecy (limited)",[199,866,867,868,870],{},"ECDH key pairs are ephemeral per pairing session. Compromising the long-term Ed25519 key later does not decrypt past traffic (the shared key is also still needed — but if both the ECDH private keys and the stored ",[132,869,239],{}," are wiped, past captures cannot be decrypted).",[181,872,873,878],{},[199,874,875],{},[118,876,877],{},"Denial-of-service resistance",[199,879,880,883,884,887],{},[132,881,882],{},"onDatagram"," wraps every message in try\u002Fcatch so a malformed packet cannot kill the discovery receiver. ",[132,885,886],{},"PeerCircuitBreaker"," skips a flaky transport for 30 s after 2 failures.",[181,889,890,895],{},[199,891,892],{},[118,893,894],{},"Privacy (directed discovery)",[199,896,897,900,901,903],{},[132,898,899],{},"LANDiscoverManager.discoverSpecificDevice"," encrypts the target ",[132,902,134],{}," with the peer's key — passive LAN observers cannot enumerate who is paired with whom.",[181,905,906,911],{},[199,907,908],{},[118,909,910],{},"Identity stability",[199,912,913,915],{},[132,914,134],{}," is derived from long-term Ed25519 key material in the platform keystore — stable across reinstalls, self-authenticating, and not tied to a phone number or email.",[287,917,919],{"id":918},"what-pairing-does-not-defend-against","What pairing does NOT defend against",[34,921,922,932,938],{},[37,923,924,927,928,931],{},[118,925,926],{},"Physical device compromise."," If an attacker gains root on a paired\ndevice, they can read the shared key from the database and impersonate\nthat peer. There is no hardware-backed key store enforcement for the\nshared transport key (only for the Ed25519 signing key, via\n",[132,929,930],{},"SignatureHelper",").",[37,933,934,937],{},[118,935,936],{},"Active relay attacks."," An attacker who can simultaneously relay BLE\nand LAN traffic between two devices that think they're pairing with each\nother could theoretically position themselves in the middle — but the\nEd25519 signature on the ECDH public key means they cannot read the\ntraffic, only relay it. This is the same trade-off as Bluetooth pairing\nwithout numeric comparison.",[37,939,940,943],{},[118,941,942],{},"Network-level blocking."," A firewall can block UDP multicast, BLE can\nbe jammed, and Wi-Fi Aware can be unavailable. The system degrades\ngracefully (BLE is the guaranteed fallback for paired peers) but cannot\nbypass an actively hostile network.",[29,945,109],{"id":946},"state-machine-recap",[114,948,949],{},[164,950],{"alt":951,"src":952},"Diagram 10","\u002Fblog\u002Fpairing-flow\u002Fdiagram-10.svg",[29,954,956],{"id":955},"further-reading","Further Reading",[34,958,959,966],{},[37,960,961,965],{},[40,962,964],{"href":963},"\u002Fblog\u002Fchat-architecture","Chat Architecture"," — what the shared key is used\nfor: peer chat send\u002Freceive, channel fan-out, presence, file downloads.",[37,967,968,971],{},[132,969,970],{},"apitest\u002Fgroups\u002Fdiscovery.sh"," — executable test plan exercising the\ndiscovery and pairing API surface end-to-end.",{"title":973,"searchDepth":974,"depth":974,"links":975},"",3,[976,978,979,980,983,987,991,994,998,999,1003,1007,1010,1011],{"id":31,"depth":977,"text":32},2,{"id":112,"depth":977,"text":43},{"id":170,"depth":977,"text":49},{"id":268,"depth":977,"text":55,"children":981},[982],{"id":289,"depth":974,"text":290},{"id":407,"depth":977,"text":61,"children":984},[985,986],{"id":422,"depth":974,"text":423},{"id":457,"depth":974,"text":458},{"id":480,"depth":977,"text":67,"children":988},[989,990],{"id":492,"depth":974,"text":493},{"id":518,"depth":974,"text":519},{"id":533,"depth":977,"text":73,"children":992},[993],{"id":545,"depth":974,"text":546},{"id":595,"depth":977,"text":79,"children":995},[996],{"id":611,"depth":974,"text":997},"Why the responder fires PairingSuccessEvent immediately on accept",{"id":636,"depth":977,"text":85},{"id":663,"depth":977,"text":91,"children":1000},[1001],{"id":683,"depth":974,"text":1002},"Why BlePairingSessionStore exists",{"id":704,"depth":977,"text":97,"children":1004},[1005],{"id":716,"depth":974,"text":1006},"Why clientId is the only persisted identifier",{"id":758,"depth":977,"text":103,"children":1008},[1009],{"id":918,"depth":974,"text":919},{"id":946,"depth":977,"text":109},{"id":955,"depth":977,"text":956},"Security","2025-01-15","This article explains how two PlainApp devices establish trust for the first time — how they discover each other, exchange keys, and arrive at the shared ChaCha20 transport key that every chat message, file transfer, and presence ping is encrypted with afterwards. The chat and channel architecture that consumes this key is covered in the separate Chat Architecture article.","md",{},true,"\u002Fblog\u002Fpairing-flow","10 min read",{"title":24,"description":1014},"How do two phones establish trust without a server? PlainApp's pairing flow: discovery, key exchange, and a shared ChaCha20 key that encrypts everything.","How Phones Pair Securely: Device Trust & Key Exchange","blog\u002Fpairing-flow","e8aH92Zo1wqzN_hD7p15BeByTb9DSkWLShP4qm4Meqs",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1026},"\u003Cpath fill=\"currentColor\" d=\"M224 128a8 8 0 0 1-8 8H59.31l58.35 58.34a8 8 0 0 1-11.32 11.32l-72-72a8 8 0 0 1 0-11.32l72-72a8 8 0 0 1 11.32 11.32L59.31 120H216a8 8 0 0 1 8 8\"\u002F>",1788009009951]